メインコンテンツに移動

HASHIBAMI

  • Login
  • ホーム
  • test
  1. ホーム

q

(日), 26/10/2025 - 7:59午前 by iroha

#!/usr/-bin/env python3
# -*- coding: utf-8 -*-
import sys, os, re, json, sqlite3, collections, shutil
from datetime import datetime
import shlex
from functools import partial
import xml.etree.ElementTree as etree
from contextlib import contextmanager
import time
import difflib
import getpass
from pathlib import Path
from urllib.parse import quote, unquote
import csv, io # <-- csv と io を追加
from datetime import datetime
import subprocess
# =================================================================
# APPLICATION NAME & CONSTANTS
# =================================================================
APP_NAME = "Akashic Index"
HISTORY_LIMIT_PER_NOTE = 10
TRASH_LIMIT = 20
# =================================================================
# THEME COLOR DEFINITIONS
# =================================================================
THEME_COLORS = {
    "Dark": {
        "base": "#353535", "text": "#dcdcdc", "input_bg": "#191919",
        "border": "#555", "item_selected_bg": "#2a82da", "item_selected_text": "#ffffff",
        "item_text": "#dcdcdc",
        "button_bg": "#4a4a4a", "button_hover": "#5a5a5a", "button_pressed": "#3a3a3a",
        "button_checked": "#2a82da", "handle": "#4a4a4a", "scrollbar": "#4a4a4a",
        "statusbar_bg": "#2a2a2a",
    },
    "Gray": {
        "base": "#606366", "text": "#f0f0f0", "input_bg": "#4a4d4f",
        "border": "#7a7d7f", "item_selected_bg": "#0078d4", "item_selected_text": "#ffffff",
        "item_text": "#f0f0f0",
        "button_bg": "#6a6d6f", "button_hover": "#7a7d7f", "button_pressed": "#5a5d5f",
        "button_checked": "#0078d4", "handle": "#6a6d6f", "scrollbar": "#6a6d6f",
        "statusbar_bg": "#4a4d4f",
    },
    "Light": {
        "base": "#dcdcdc", "text": "#333333", "input_bg": "#f0f0f0",
        "border": "#6D6D6D", "item_selected_bg": "#46a8f8", "item_selected_text": "#ffffff",
        "item_text": "#333333",
        "button_bg": "#c8c8c8", "button_hover": "#6fb1e7", "button_pressed": "#46a8f8",
        "button_checked": "#46a8f8", "handle": "#b0b0b0", "scrollbar": "#b0b0b0",
        "statusbar_bg": "#c0c0c0",
    }
}
PREVIEW_THEME_COLORS = {
    "Dark": {
        "bg": "#2d2d3d", "text": "#dcdcdc", "border": "#555", "header_bg": "rgba(255, 255, 255, 0.1)",
        "code_bg": "rgba(0, 0, 0, 0.2)", "quote_border": "#777", "quote_text": "#bbb",
        "link": "#58a6ff", "link_bg": "rgba(88, 166, 255, 0.15)", "highlight": "#b3a300",
        "strong": "#ff8c82", "table_cell_bg": "transparent", "table_cell_alt_bg": "rgba(255, 255, 255, 0.05)",
        "diff_header": "rgba(255,255,255,0.1)", "diff_add": "#144621", "diff_chg": "#5a5015", "diff_sub": "#5e1c1c"
    },
    "Gray": {
        "bg": "#55585a", "text": "#f0f0f0", "border": "#7a7d7f", "header_bg": "rgba(255, 255, 255, 0.08)",
        "code_bg": "#404345", "quote_border": "#888", "quote_text": "#d0d0d0",
        "link": "#60afff", "link_bg": "rgba(96, 175, 255, 0.15)", "highlight": "#d0c000",
        "strong": "#ff9a92", "table_cell_bg": "#606366", "table_cell_alt_bg": "#5a5d5f",
        "diff_header": "#5a5d5f", "diff_add": "#2a553a", "diff_chg": "#606030", "diff_sub": "#6b3333"
    },
    "Light": {
        "bg": "#fcfcfc", "text": "#2c2c2c", "border": "#717171", "header_bg": "#9f9f9f",
        "code_bg": "#b3b3b3", "quote_border": "#c0c0c0", "quote_text": "#555555",
        "link": "#0d6efd", "link_bg": "rgba(13, 110, 253, 0.08)", "highlight": "#fff000",
        "strong": "#c82828", "table_cell_bg": "#fdfdfd", "table_cell_alt_bg": "#f2f2f2",
        "diff_header": "#e8e8e8", "diff_add": "#e6ffed", "diff_chg": "#fff8c5", "diff_sub": "#ffebe9"
    }
}
# ------------------------------------------------------------
# PyQt6 Imports
# ------------------------------------------------------------
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QTextEdit, QFileDialog, QMessageBox,
    QWidget, QVBoxLayout, QToolBar, QTreeView, QSplitter,
    QStyle, QInputDialog, QToolButton, QMenu, QDialog, QFormLayout,
    QLineEdit, QDialogButtonBox, QPushButton, QCheckBox,
    QHBoxLayout, QSizePolicy, QScrollArea, QTabWidget, QFontComboBox,
    QSpinBox, QLabel, QStackedWidget, QListView, QComboBox, QCompleter,
    QFrame, QListWidget, QListWidgetItem, QStyledItemDelegate, QStyleOptionViewItem,
    QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView
)
from PyQt6.QtGui import (
    QAction, QFont, QColor, QPalette, QTextCursor, QIcon, QDesktopServices,QBrush,
    QStandardItemModel, QStandardItem, QActionGroup, QTextDocument, QAbstractTextDocumentLayout, QKeySequence
)
from PyQt6.QtCore import (
    QAbstractItemModel, QModelIndex, Qt, QTimer, QUrl,QSharedMemory,
    QByteArray, QSize, QMimeData, pyqtSignal, QAbstractListModel,
    QStandardPaths, QCoreApplication, QStringListModel, QEvent, QFileSystemWatcher
)
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebEngineCore import QWebEnginePage, QWebEngineHistory
# ------------------------------------------------------------
# Markdown Imports
# ------------------------------------------------------------
import markdown
from markdown.extensions import Extension
from markdown.extensions.footnotes import FootnoteExtension
from mdx_truly_sane_lists.mdx_truly_sane_lists import TrulySaneListExtension
from markdown.inlinepatterns import SimpleTagInlineProcessor, ImageInlineProcessor, InlineProcessor
from markdown.postprocessors import Postprocessor
from markdown.blockprocessors import BlockProcessor
# =================================================================
# 1. MARKDOWN EXTENSIONS
# =================================================================
class IndentBlockProcessor(BlockProcessor):
    RE = re.compile(r'^(?:\t| {4})')
    # ★★★ 追加: 箇条書き/番号付きリストの先頭にマッチする正規表現 ★★★
    # `* `, `- `, `+ `, `1. ` などを想定
    LIST_RE = re.compile(r'^(?:[-*+]|\d+\.)\s')
    def test(self, parent, block):
        # ★★★ ここからが修正されたロジック ★★★
        # 1. ブロックがインデントされているか?
        is_indented = bool(self.RE.match(block))
        if not is_indented:
            return False # インデントされていなければ、このプロセッサの対象外
        # 2. インデントされている場合、それがリストアイテムではないか?
        #    行頭の空白をすべて除去して、リストの記号があるかチェック
        content_part = block.lstrip()
        is_list_item = bool(self.LIST_RE.match(content_part))
        
        # 3. インデントされていて、かつ、リストアイテムではない場合のみ True を返す
        return is_indented and not is_list_item
        # ★★★ 修正ロジックここまで ★★★
    def run(self, parent, blocks):
        original_block = blocks.pop(0)
        indented_blocks = [original_block]
        while blocks and self.RE.match(blocks[0]):
            indented_blocks.append(blocks.pop(0))
        block_string = '\n'.join(indented_blocks)
        m = re.match(r'^((\t| {4})+)', original_block)
        indent_level = 0
        if m:
            indent_str = m.group(1)
            indent_level = indent_str.count('\t') + indent_str.count(' ' * 4)
        div = etree.SubElement(parent, 'div')
        div.set('class', f'indent-{indent_level}')
        lines_to_parse = []
        for line in block_string.split('\n'):
            if line.startswith('\t'):
                lines_to_parse.append(line[1:])
            elif line.startswith(' ' * 4):
                lines_to_parse.append(line[4:])
            else:
                lines_to_parse.append(line)
        
        new_block_content = '\n'.join(lines_to_parse)
        self.parser.parseBlocks(div, [new_block_content])
        return True
class IndentExtension(Extension):
    def extendMarkdown(self, md):
        md.parser.blockprocessors.register(IndentBlockProcessor(md.parser), 'indent', 101)
class AdmonitionTreeprocessor(markdown.treeprocessors.Treeprocessor):
    """
    > [!NOTE] 形式のブロックを処理し、必要に応じてブロックを分割するTreeprocessor
    """
    def __init__(self, md):
        super().__init__(md)
        self.RE = re.compile(r'\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](.*)')
    def run(self, root):
        # 親要素と子の対応を記録
        parent_map = {c: p for p in root.iter() for c in p}
        
        # iter()はライブオブジェクトを返すため、リストにコピーしてから処理する
        blockquotes = list(root.iter('blockquote'))
        
        for blockquote in blockquotes:
            # 既に処理済みの場合はスキップ
            if 'admonition' in blockquote.get('class', ''):
                continue
            # 最初の段落がAdmonitionヘッダーで始まっているか確認
            first_p = blockquote.find('p')
            if first_p is None or first_p.text is None or not self.RE.match(first_p.text.strip()):
                continue
            # --- Admonitionブロックの分割処理 ---
            new_blocks = []
            current_admonition = None
            for child in list(blockquote): # 子要素を走査
                is_header = False
                if child.tag == 'p' and child.text:
                    m = self.RE.match(child.text.strip())
                    if m:
                        is_header = True
                        # 既存のAdmonitionがあればリストに追加
                        if current_admonition is not None:
                            new_blocks.append(current_admonition)
                        # 新しいAdmonitionを作成
                        ad_type = m.group(1).lower()
                        title_text = m.group(2).strip() or m.group(1).title()
                        icons = {
                            'note': 'ℹ️', 'tip': '💡', 'important': '⭐',
                            'warning': '⚠️', 'caution': '🔥'
                        }
                        icon = icons.get(ad_type, '')
                        current_admonition = etree.Element('blockquote')
                        current_admonition.set('class', f'admonition admonition-{ad_type}')
                        
                        title_div = etree.Element('div')
                        title_div.set('class', 'admonition-title')
                        title_div.text = f'{icon} {title_text}'
                        current_admonition.append(title_div)
                        
                        # ヘッダー行を段落から削除
                        lines = child.text.strip().split('\n', 1)
                        if len(lines) > 1 and lines[1].strip():
                            child.text = lines[1]
                            current_admonition.append(child)
                        # ヘッダー行のみの段落はここで破棄される
                
                if not is_header:
                    if current_admonition is not None:
                        current_admonition.append(child)
            # 最後のAdmonitionを追加
            if current_admonition is not None:
                new_blocks.append(current_admonition)
            # 元のblockquoteを新しいブロック群で置き換える
            if new_blocks and blockquote in parent_map:
                parent = parent_map[blockquote]
                # list()で静的なコピーを作成してからindex()を呼ぶ
                try:
                    idx = list(parent).index(blockquote)
                    # 元の要素を削除
                    parent.remove(blockquote)
                    # 新しい要素を逆順に挿入(同じ位置に順番通りに入るように)
                    for block in reversed(new_blocks):
                        parent.insert(idx, block)
                except ValueError:
                    # 親から削除されたなどの理由で要素が見つからない場合
                    pass
        return root
class AdmonitionExtension(Extension):
    """
    GitHub風のAdmonitionを追加するMarkdown拡張
    """
    def extendMarkdown(self, md):
        md.treeprocessors.register(AdmonitionTreeprocessor(md), 'admonition', 15)
class ColoredHighlightPattern(InlineProcessor):
    """
    @@r:テキスト@@ のような蛍光ペン構文を処理する
    """
    def handleMatch(self, m, data):
        # <mark> タグを作成
        el = etree.Element("mark")
        
        # 色の指定を取得 (r, g, b)
        color_char = m.group(2)
        color_map = {'r': 'red', 'g': 'green', 'b': 'blue'}
        color_name = color_map.get(color_char, 'yellow') # デフォルトは黄色
        
        # CSSクラスを設定 (例: "highlight-red")
        el.set('class', f'highlight-{color_name}')
        
        # ハイライトするテキストを設定
        el.text = m.group(3)
        
        return el, m.start(0), m.end(0)
class ColoredHighlightExtension(Extension):
    """
    色付き蛍光ペンを追加するMarkdown拡張
    """
    def extendMarkdown(self, md):
        # 正規表現パターン: (@@)(r|g|b):(.+?)(@@)
        md.inlinePatterns.register(ColoredHighlightPattern(r'(@@)(r|g|b):(.+?)(@@)', md), 'colored_highlight', 179)
class UnderlineExtension(Extension):
    def extendMarkdown(self, md):
        md.inlinePatterns.register(SimpleTagInlineProcessor(r'(\+\+)(.+?)(\+\+)', 'u'), 'underline', 175)
class StrikeExtension(Extension):
    def extendMarkdown(self, md):
        md.inlinePatterns.register(SimpleTagInlineProcessor(r'(~~)(.+?)(~~)', 'del'), 'strikethrough', 176)
class IdLinkPattern(InlineProcessor):
    def handleMatch(self, m, data):
        el = etree.Element("a")
        el.text = m.group('text')
        book_name = m.group('book')
        note_id = m.group('id')
        
        href = f'app://note/{note_id}'
        if book_name:
            href = f'app://note/{book_name}/{note_id}'
        
        el.set('href', href)
        el.set('class', 'internal-link')
        el.set('data-id', note_id)
        if book_name:
            el.set('data-book', book_name)
        return el, m.start(0), m.end(0)
class IdLinkExtension(Extension):
    def extendMarkdown(self, md):
        md.inlinePatterns.register(IdLinkPattern(r'\[(?P<text>.*?)\]\((?:(?P<book>[\w\d_-]+):)?(?P<id>\d+)\)', md), 'id_link', 177)
class TagProcessor(InlineProcessor):
    RE = r'(?:^|\s)(#(?![#\s])[\w\d_/-]+)' # ← こちらを有効にする
    # RE = r'(?<=\s)(#(?![#\s])[\w\d_/-]+)' # ← こちらは削除またはコメントアウトする
    def __init__(self, pattern, md):
        super().__init__(pattern, md)
    def handleMatch(self, m, data):
        # m.group(1) に '#tag' が入る
        tag_with_hash = m.group(1)
        
        # リンク先URL用に、'#' を除いたクリーンな名前を取得
        clean_tag = tag_with_hash.lstrip('#')
        
        el = etree.Element('a') 
        encoded_tag = quote(clean_tag)
        el.set('href', f'app://tag/{encoded_tag}')
        el.set('class', 'tag-link')
        el.set('data-tag-name', tag_with_hash)
        el.text = tag_with_hash
        
        # マッチした部分のうち、タグ本体(#tag)だけを置き換える
        # この handleMatch メソッドは、修正後の正規表現で正しく動作するため変更は不要です。
        return el, m.start(1), m.end(1)
class TagExtension(Extension):
    def extendMarkdown(self, md):
        md.inlinePatterns.register(TagProcessor(TagProcessor.RE, md), 'tag_link', 178)
class ImageLinkPattern(ImageInlineProcessor):
    def handleMatch(self, m, data):
        a = etree.Element('a')
        a.set('href', m.group('link'))
        a.set('target', '_blank')
        a.set('class', 'image-link')
        img = etree.Element('img')
        img.set('src', m.group('link'))
        img.set('alt', m.group('alt'))
        if 'title' in m.groupdict() and m.group('title'):
            img.set('title', m.group('title'))
        a.append(img)
        return a, m.start(0), m.end(0)
class ImageLinkExtension(Extension):
    def extendMarkdown(self, md):
        md.inlinePatterns.register(ImageLinkPattern(r'!\[(?P<alt>.*?)\]\((?P<link>.*?)(?:\s+"(?P<title>.*?)")?\)', md), 'image_link', 170)
class ExternalLinkPostprocessor(Postprocessor):
    def run(self, text):
        def add_link_class(match):
            a_tag = match.group(0)
            if 'class=' in a_tag: return a_tag
            href_match = re.search(r'href="([^"]+)"', a_tag)
            if not href_match: return a_tag
            href = href_match.group(1).lower()
            # --- ▼▼▼ この3行を追加してください ▼▼▼ ---
            # ページ内リンク(例: href="#header")の場合、クラスを付けずにそのまま返す
            if href.startswith('#'):
                return a_tag
            # --- ▲▲▲ 追加ここまで ▲▲▲ ---
            link_class = ''
            if href.startswith('http://') or href.startswith('https://'): link_class = 'external-link-web'
            elif ':' not in href or href.startswith('file:'): link_class = 'external-link-local'
            if link_class: return a_tag.replace('<a', f'<a class="{link_class}"')
            return a_tag
        return re.sub(r'<a[^>]*>', add_link_class, text)
class ExternalLinkExtension(Extension):
    def extendMarkdown(self, md):
        md.postprocessors.register(ExternalLinkPostprocessor(md), 'external_link_class', 25)
# =================================================================
# 2. CUSTOM WIDGETS
# =================================================================
class HighlightDelegate(QStyledItemDelegate):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.highlight_terms = []
    def set_highlight_terms(self, terms):
        self.highlight_terms = [re.escape(term) for term in terms if term]
    def paint(self, painter, option, index):
        self.initStyleOption(option, index)
        if not self.highlight_terms or not option.text:
            super().paint(painter, option, index)
            return
        original_text = option.text
        highlighted_text = original_text
        for term in self.highlight_terms:
            highlighted_text = re.sub(f'({term})', r'<mark>\1</mark>', highlighted_text, flags=re.IGNORECASE)
        if highlighted_text == original_text:
            super().paint(painter, option, index)
            return
        style = option.widget.style() if option.widget else QApplication.style()
        option.text = ""
        style.drawControl(QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget)
        doc = QTextDocument()
        if option.state & QStyle.StateFlag.State_Selected:
            text_color = option.palette.color(QPalette.ColorGroup.Active, QPalette.ColorRole.HighlightedText)
        else:
            text_color = option.palette.color(QPalette.ColorRole.Text)
        doc.setDefaultStyleSheet(f"body {{ color: {text_color.name()}; }} mark {{ background-color: yellow; color: black; }}")
        doc.setHtml(highlighted_text)
        
        text_rect = style.subElementRect(QStyle.SubElement.SE_ItemViewItemText, option, option.widget)
        ctx = QAbstractTextDocumentLayout.PaintContext()
        painter.save()
        painter.translate(text_rect.topLeft())
        painter.setClipRect(text_rect.translated(-text_rect.topLeft()))
        doc.documentLayout().draw(painter, ctx)
        painter.restore()
class PlainTextEdit(QTextEdit):
    def insertFromMimeData(self, source: QMimeData) -> None:
        if source.hasText(): self.insertPlainText(source.text())
        else: super().insertFromMimeData(source)
            
    def keyPressEvent(self, event):
        key = event.key()
        modifiers = event.modifiers()
        if key == Qt.Key.Key_Tab or key == Qt.Key.Key_Backtab:
            cursor = self.textCursor()
            if modifiers == Qt.KeyboardModifier.ShiftModifier or key == Qt.Key.Key_Backtab: self.unindent_selection(cursor)
            else: self.indent_selection(cursor)
            event.accept()
            return
        super().keyPressEvent(event)
    def select_item_at_index(self, index: QModelIndex):
        """指定されたインデックスのアイテムを選択状態にし、その内容を表示させる"""
        if index and index.isValid():
            # 1. ツリービュー上でアイテムを選択し、表示範囲内にスクロールする
            self.tree_view.setCurrentIndex(index)
            self.tree_view.scrollTo(index, QTreeView.ScrollHint.PositionAtCenter)
            
            # 2. on_item_selection_changed を手動で呼び出して、エディタの内容を更新する
            #    (setCurrentIndexだけではシグナルが発行されない場合があるため)
            self.on_item_selection_changed(index, QModelIndex())
    def indent_selection(self, cursor):
        if cursor.hasSelection():
            start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd()
            cursor.setPosition(start_pos); first_block_num = cursor.blockNumber()
            cursor.setPosition(end_pos); last_block_num = cursor.blockNumber()
            if cursor.atBlockStart() and end_pos != start_pos: last_block_num -= 1
            cursor.beginEditBlock()
            for block_num in range(first_block_num, last_block_num + 1):
                QTextCursor(self.document().findBlockByNumber(block_num)).insertText('\t')
            cursor.endEditBlock()
        else: cursor.insertText('\t')
            
    def unindent_selection(self, cursor):
        start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd()
        cursor.setPosition(start_pos); first_block_num = cursor.blockNumber()
        cursor.setPosition(end_pos); last_block_num = cursor.blockNumber()
        if cursor.atBlockStart() and end_pos != start_pos: last_block_num -= 1
        cursor.beginEditBlock()
        for block_num in range(first_block_num, last_block_num + 1):
            block = self.document().findBlockByNumber(block_num)
            text = block.text()
            temp_cursor = QTextCursor(block)
            if text.startswith('\t'): temp_cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, 1); temp_cursor.removeSelectedText()
            elif text.startswith(' ' * 4): temp_cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, 4); temp_cursor.removeSelectedText()
        cursor.endEditBlock()
# 92行目あたりにある CustomWebPage クラスを以下に置き換えてください
class CustomWebPage(QWebEnginePage):
    # 'app://' スキームを持つ内部リンクがクリックされたとき専用のシグナル
    linkClicked = pyqtSignal(QUrl)
    def acceptNavigationRequest(self, url, _type, isMainFrame):
        """
        QWebEngineView内で発生するすべてのナビゲーション要求をハンドルする。
        """
        # ユーザーがメインフレーム内のリンクをクリックした場合のみ、カスタム処理を実行
        if _type == QWebEnginePage.NavigationType.NavigationTypeLinkClicked and isMainFrame:
            
            # URLのスキーム(http, file, appなど)を判定
            if url.scheme() == 'app':
                # 【内部リンクの処理】
                # URLが 'app://' で始まる場合、アプリケーション内で処理すべきリンクと判断。
                # 'linkClicked' シグナルを発行して、メインの制御クラス(AppController)に処理を委ねる。
                self.linkClicked.emit(url)
            else:
                # 【外部リンクの処理】
                # URLが 'http://', 'file://' などで始まる場合、外部リンクと判断。
                # OSの標準機能(デフォルトブラウザやファイラー)を使って開く。
                QDesktopServices.openUrl(url)
            
            # 内部・外部リンクどちらの場合でも、プレビュー画面自体がページ遷移するのを防ぐため、
            # 必ず False を返して、デフォルトのナビゲーション動作をキャンセルする。
            return False
        
        # リンククリック以外のナビゲーション要求(リダイレクトなど)は、デフォルトの動作を許可する。
        return super().acceptNavigationRequest(url, _type, isMainFrame)
class CustomWebEngineView(QWebEngineView):
    def __init__(self, parent_panel, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.parent_panel = parent_panel
    def contextMenuEvent(self, event):
        menu = self.createStandardContextMenu()
        if self.parent_panel.export_button.isEnabled():
            menu.addSeparator()
            export_submenu = QMenu("エクスポート", self)
            export_submenu.addActions(self.parent_panel.export_button.menu().actions())
            menu.addMenu(export_submenu)
        menu.popup(event.globalPos())
class SearchLineEdit(QLineEdit):
    def mouseDoubleClickEvent(self, event):
        super().mouseDoubleClickEvent(event)
        
        # このウィジェットに設定されているコンプリータ機能を取得
        completer = self.completer()
        if completer:
            # ★★★ ここからが修正点です ★★★
            # 1. 現在の入力内容(空文字列)を補完の基準として設定する
            completer.setCompletionPrefix(self.text())
            
            # 2. 候補のポップアップ表示を強制的にトリガーする
            completer.complete()
# =================================================================
# 3. DIALOGS
# =================================================================
class ExcelPasteTableWidget(QTableWidget):
    def keyPressEvent(self, event):
        """キー入力を処理し、コピー、ペースト、Enterキーのカスタム動作を実装する"""
        
        # 1. コピー操作 (Ctrl+C)
        if event.matches(QKeySequence.StandardKey.Copy):
            self.copy_selection()
            event.accept()
        # 2. ペースト操作 (Ctrl+V)
        elif event.matches(QKeySequence.StandardKey.Paste):
            self.paste_from_clipboard()
            event.accept()
        # 3. Enterキー操作 (編集確定後のセル移動)
        elif event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter) and self.state() != QAbstractItemView.State.EditingState:
            current_row = self.currentRow()
            current_col = self.currentColumn()
            if current_row >= 0 and current_col >= 0:
                next_row = current_row + 1
                if next_row >= self.rowCount():
                    self.insertRow(self.rowCount())
                    
                    parent_dialog = self.parent()
                    while parent_dialog is not None and not isinstance(parent_dialog, TableDialog):
                        parent_dialog = parent_dialog.parent()
                    if isinstance(parent_dialog, TableDialog):
                        parent_dialog.rows_spin.setValue(self.rowCount())
                self.setCurrentCell(next_row, current_col)
                event.accept()
        # 4. 上記以外のキー操作は、デフォルトの動作に任せる
        else:
            super().keyPressEvent(event)
    def copy_selection(self):
        """選択されている範囲のセルデータをTSV形式でクリップボードにコピーする"""
        selection_ranges = self.selectedRanges()
        if not selection_ranges:
            return
        # 最初の選択範囲のみを対象とする(一般的な利用ケースをカバー)
        selection_range = selection_ranges[0]
        top_row = selection_range.topRow()
        bottom_row = selection_range.bottomRow()
        left_col = selection_range.leftColumn()
        right_col = selection_range.rightColumn()
        clipboard_rows = []
        for row in range(top_row, bottom_row + 1):
            row_data = []
            for col in range(left_col, right_col + 1):
                item = self.item(row, col)
                # セルにアイテムが存在すればそのテキストを、なければ空文字を追加
                row_data.append(item.text() if item else "")
            
            # タブ文字でセルデータを結合して1行分の文字列を作成
            clipboard_rows.append("\t".join(row_data))
        # 改行文字で行データを結合して最終的なクリップボード用文字列を作成
        clipboard_string = "\n".join(clipboard_rows)
        
        # クリップボードに設定
        QApplication.clipboard().setText(clipboard_string)
    def paste_from_clipboard(self):
        """
        クリップボードからタブ区切りテキストを貼り付けます。
        セル内改行に対応し、データに合わせてテーブルサイズを自動拡張します。
        """
        clipboard = QApplication.clipboard()
        mime_data = clipboard.mimeData()
        if not mime_data.hasText():
            return
        text = mime_data.text()
        if not text:
            return
            
        try:
            f = io.StringIO(text)
            reader = csv.reader(f, delimiter='\t')
            parsed_data = list(reader)
        except Exception as e:
            print(f"Clipboard TSV parsing failed, falling back to simple split: {e}")
            rows_text = text.strip('\n\r').split('\n')
            parsed_data = [row.split('\t') for row in rows_text]
        if not parsed_data:
            return
        start_row = self.currentRow() if self.currentRow() >= 0 else 0
        start_col = self.currentColumn() if self.currentColumn() >= 0 else 0
        
        num_pasted_rows = len(parsed_data)
        num_pasted_cols = max(len(row) for row in parsed_data) if parsed_data else 0
        if self.rowCount() < start_row + num_pasted_rows:
            self.setRowCount(start_row + num_pasted_rows)
        if self.columnCount() < start_col + num_pasted_cols:
            self.setColumnCount(start_col + num_pasted_cols)
        for r_idx, row_data in enumerate(parsed_data):
            for c_idx, cell_text in enumerate(row_data):
                target_row = start_row + r_idx
                target_col = start_col + c_idx
                self.setItem(target_row, target_col, QTableWidgetItem(cell_text))
        parent = self.parent()
        while parent is not None and not isinstance(parent, TableDialog):
            parent = parent.parent()
        if isinstance(parent, TableDialog):
            parent.rows_spin.blockSignals(True)
            parent.cols_spin.blockSignals(True)
            parent.rows_spin.setValue(self.rowCount())
            parent.cols_spin.setValue(self.columnCount())
            parent.rows_spin.blockSignals(False)
            parent.cols_spin.blockSignals(False)
            parent.update_table_dimensions()
    def contextMenuEvent(self, event):
        """右クリックメニューを表示し、行・列の追加削除機能を提供する"""
        menu = QMenu(self)
        
        row = self.rowAt(event.pos().y())
        col = self.columnAt(event.pos().x())
        if row >= 0:
            menu.addAction("選択行の上に挿入", lambda: self._insert_row(row))
            menu.addAction("選択行の下に挿入", lambda: self._insert_row(row + 1))
            menu.addAction("選択行を削除", lambda: self._remove_row(row))
            menu.addSeparator()
        if col >= 0:
            menu.addAction("選択列の左に挿入", lambda: self._insert_col(col))
            menu.addAction("選択列の右に挿入", lambda: self._insert_col(col + 1))
            menu.addAction("選択列を削除", lambda: self._remove_col(col))
        if menu.actions():
            menu.exec(event.globalPos())
            
    def _update_parent_dialog(self):
        """親ダイアログ(TableDialog)を見つけ出し、行数・列数のスピンボックスを更新する"""
        parent = self.parent()
        # 親をたどって TableDialog を見つける
        while parent is not None and not isinstance(parent, TableDialog):
            parent = parent.parent()
        # TableDialog が見つかった場合のみ処理を実行
        if isinstance(parent, TableDialog):
            # スピンボックスのvalueChangedシグナルが発火しないように一時的にブロック
            parent.rows_spin.blockSignals(True)
            parent.cols_spin.blockSignals(True)
            
            # テーブルの現在の行数と列数をスピンボックスに設定
            parent.rows_spin.setValue(self.rowCount())
            parent.cols_spin.setValue(self.columnCount())
            
            # シグナルのブロックを解除
            parent.rows_spin.blockSignals(False)
            parent.cols_spin.blockSignals(False)
    def _insert_row(self, row):
        self.insertRow(row)
        self._update_parent_dialog() # 親ダイアログを更新
    def _remove_row(self, row):
        # 行数が1未満にならないように保護
        if self.rowCount() > 1:
            self.removeRow(row)
            self._update_parent_dialog() # 親ダイアログを更新
    def _insert_col(self, col):
        self.insertColumn(col)
        self._update_parent_dialog() # 親ダイアログを更新
        # 列の配置設定ウィジェットも更新する
        parent = self.parent()
        while parent is not None and not isinstance(parent, TableDialog):
            parent = parent.parent()
        if isinstance(parent, TableDialog):
            parent.update_table_dimensions()
    def _remove_col(self, col):
        # 列数が1未満にならないように保護
        if self.columnCount() > 1:
            self.removeColumn(col)
            self._update_parent_dialog() # 親ダイアログを更新
            # 列の配置設定ウィジェットも更新する
            parent = self.parent()
            while parent is not None and not isinstance(parent, TableDialog):
                parent = parent.parent()
            if isinstance(parent, TableDialog):
                parent.update_table_dimensions()
    
class LoginDialog(QDialog):
    """管理者ログイン用のパスワード入力ダイアログ"""
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("管理者ログイン")
        layout = QFormLayout(self)
        self.password_edit = QLineEdit()
        self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
        layout.addRow("パスワード:", self.password_edit)
        btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        btns.accepted.connect(self.accept)
        btns.rejected.connect(self.reject)
        layout.addWidget(btns)
    def get_password(self):
        return self.password_edit.text()
class StatisticsDialog(QDialog):
    """集計結果を表示するためのダイアログ"""
    def __init__(self, note_counts, edit_counts, parent=None):
        super().__init__(parent)
        self.setWindowTitle("集計機能")
        self.setMinimumSize(600, 400)
        
        main_layout = QVBoxLayout(self)
        self.tabs = QTabWidget()
        main_layout.addWidget(self.tabs)
        # ノート総数ランキングタブ
        note_tab = QWidget()
        note_layout = QVBoxLayout(note_tab)
        note_table = QTableWidget()
        note_table.setColumnCount(3)
        note_table.setHorizontalHeaderLabels(["順位", "ユーザー名", "ノート総数"])
        note_table.setRowCount(len(note_counts))
        for i, (user, count) in enumerate(note_counts):
            note_table.setItem(i, 0, QTableWidgetItem(str(i + 1)))
            note_table.setItem(i, 1, QTableWidgetItem(user))
            note_table.setItem(i, 2, QTableWidgetItem(str(count)))
        note_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
        note_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        note_layout.addWidget(note_table)
        self.tabs.addTab(note_tab, "ユーザー別 ノート総数ランキング")
        # 編集回数ランキングタブ
        edit_tab = QWidget()
        edit_layout = QVBoxLayout(edit_tab)
        edit_table = QTableWidget()
        edit_table.setColumnCount(3)
        edit_table.setHorizontalHeaderLabels(["順位", "ユーザー名", "総編集回数"])
        edit_table.setRowCount(len(edit_counts))
        for i, (user, count) in enumerate(edit_counts):
            edit_table.setItem(i, 0, QTableWidgetItem(str(i + 1)))
            edit_table.setItem(i, 1, QTableWidgetItem(str(count)))
        edit_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
        edit_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        edit_layout.addWidget(edit_table)
        self.tabs.addTab(edit_tab, "ユーザー別 総編集回数ランキング")
        btn_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
        btn_box.accepted.connect(self.accept)
        main_layout.addWidget(btn_box)
class TableDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("テーブルの挿入")
        self.setMinimumSize(500, 400)
        layout = QVBoxLayout(self)
        form_layout = QFormLayout()
        
        # --- ▼▼▼ 変更箇所 ここから ▼▼▼ ---
        # 1. 行数と列数のスピンボックスを作成
        self.rows_spin = QSpinBox()
        self.rows_spin.setRange(1, 100)
        self.rows_spin.setValue(6)
        self.cols_spin = QSpinBox()
        self.cols_spin.setRange(1, 20)
        self.cols_spin.setValue(4)
        # 2. 水平レイアウトを作成し、行数と列数のウィジェットを配置
        size_container = QWidget()
        size_layout = QHBoxLayout(size_container)
        size_layout.setContentsMargins(0, 0, 0, 0)  # 余分な余白を削除
        size_layout.addWidget(QLabel("行数:"))
        size_layout.addWidget(self.rows_spin)
        size_layout.addWidget(QLabel("列数:"))
        size_layout.addWidget(self.cols_spin)
        size_layout.addStretch()  # ウィジェットを左に寄せる
        # 3. 作成した水平レイアウトをフォームレイアウトの1行として追加
        form_layout.addRow("サイズ:", size_container)
        # --- ▲▲▲ 変更箇所 ここまで ▲▲▲ ---
        
        self.alignment_layout_container = QWidget()
        self.alignment_layout = QHBoxLayout(self.alignment_layout_container)
        self.alignment_layout.setContentsMargins(0,0,0,0)
        self.alignment_widgets = []
        form_layout.addRow("各列の配置:", self.alignment_layout_container)
        
        self.header_cb = QCheckBox("最初の行をヘッダーにする")
        self.header_cb.setChecked(True)
        form_layout.addRow(self.header_cb)
        
        layout.addLayout(form_layout)
        self.table_widget = ExcelPasteTableWidget()
        layout.addWidget(self.table_widget)
        
        btn_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        btn_box.accepted.connect(self.accept)
        btn_box.rejected.connect(self.reject)
        
        ok_button = btn_box.button(QDialogButtonBox.StandardButton.Ok)
        ok_button.setAutoDefault(False)
        ok_button.setDefault(False)     
        
        layout.addWidget(btn_box)
        self.rows_spin.valueChanged.connect(self.update_table_dimensions)
        self.cols_spin.valueChanged.connect(self.update_table_dimensions)
        self.update_table_dimensions()
    def update_table_dimensions(self):
        for widget in self.alignment_widgets:
            self.alignment_layout.removeWidget(widget)
            widget.deleteLater()
        self.alignment_widgets.clear()
        
        num_cols = self.cols_spin.value()
        for i in range(num_cols):
            combo = QComboBox()
            combo.addItems(["左寄せ", "中央揃え", "右寄せ"])
            self.alignment_layout.addWidget(combo)
            self.alignment_widgets.append(combo)
        self.table_widget.setRowCount(self.rows_spin.value())
        self.table_widget.setColumnCount(num_cols)
        headers = [chr(ord('A') + i) for i in range(num_cols)]
        self.table_widget.setHorizontalHeaderLabels(headers)
    def get_markdown_table(self) -> str:
        rows, cols, has_header = self.table_widget.rowCount(), self.table_widget.columnCount(), self.header_cb.isChecked()
        md_lines = []
        start_row_for_data = 0
        
        def format_cell_text(text):
            # パイプ文字をエスケープし、改行を<br>タグに変換
            return text.replace('|', '\\|').replace('\n', '<br>')
        if has_header and rows > 0:
            header_data = [format_cell_text(item.text() if item else " ") for c in range(cols) for item in [self.table_widget.item(0, c)]]
            md_lines.append(f"| {' | '.join(header_data)} |")
            align_map = {"左寄せ": "---", "中央揃え": ":---:", "右寄せ": "---:"}
            separator_parts = [align_map.get(combo.currentText(), "---") for combo in self.alignment_widgets]
            md_lines.append(f"|{'|'.join(separator_parts)}|")
            
            start_row_for_data = 1
        
        for r in range(start_row_for_data, rows):
            row_data = [format_cell_text(item.text() if item else " ") for c in range(cols) for item in [self.table_widget.item(r, c)]]
            md_lines.append(f"| {' | '.join(row_data)} |")
            
        return "\n".join(md_lines)
class TagSelectionDialog(QDialog):
    def __init__(self, all_tags, parent=None):
        super().__init__(parent)
        self.setWindowTitle("タグの一括追加")
        self.setMinimumWidth(300)
        layout = QVBoxLayout(self)
        # --- ▼▼▼ 機能追加: タグをフィルタするための入力ボックス ▼▼▼ ---
        self.filter_input = QLineEdit()
        self.filter_input.setPlaceholderText("タグをフィルタ...")
        self.filter_input.setClearButtonEnabled(True)
        self.filter_input.textChanged.connect(self.filter_tags)
        layout.addWidget(self.filter_input)
        # --- ▲▲▲ 機能追加ここまで ▲▲▲ ---
        self.list_widget = QListWidget()
        self.list_widget.setSelectionMode(QListWidget.SelectionMode.NoSelection)
        layout.addWidget(self.list_widget)
        for tag, count in all_tags:
            item = QListWidgetItem(f"{tag} ({count})")
            item.setData(Qt.ItemDataRole.UserRole, tag)
            item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
            item.setCheckState(Qt.CheckState.Unchecked)
            self.list_widget.addItem(item)
            
        btn_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        btn_box.accepted.connect(self.accept)
        btn_box.rejected.connect(self.reject)
        layout.addWidget(btn_box)
        
    def get_selected_tags(self):
        return [self.list_widget.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.list_widget.count()) if self.list_widget.item(i).checkState() == Qt.CheckState.Checked]
    def filter_tags(self, text):
        """入力されたテキストに応じてタグのリストをフィルタリングする"""
        filter_text = text.lower()
        for i in range(self.list_widget.count()):
            item = self.list_widget.item(i)
            item_text = item.text().lower()
            # アイテムのテキストにフィルターテキストが含まれていれば表示、なければ非表示
            item.setHidden(filter_text not in item_text)
class LinkDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("リンクを挿入"); layout = QFormLayout(self)
        self.text_edit = QLineEdit(); self.url_edit = QLineEdit()
        layout.addRow("表示テキスト:", self.text_edit); layout.addRow("URL:", self.url_edit)
        btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
        layout.addWidget(btns)
    def get_data(self): return self.text_edit.text(), self.url_edit.text()
class SelectItemDialog(QDialog):
    def __init__(self, db_manager, settings_manager, title, parent=None):
        super().__init__(parent)
        self.db_manager = db_manager
        # ▼▼▼ 変更点: 受け取った settings_manager をインスタンス変数として保持します ▼▼▼
        self.settings_manager = settings_manager
        self.setWindowTitle(title)
        self.resize(400, 500)
        
        layout = QVBoxLayout(self)
        self.search_input = QLineEdit()
        self.search_input.setPlaceholderText("🔍ノート名 or ID で検索...")
        self.search_input.setClearButtonEnabled(True)
        self.search_input.textChanged.connect(self.filter_tree)
        layout.addWidget(self.search_input)
        self.tree = QTreeView()
        self.tree.setHeaderHidden(True)
        self.model = QStandardItemModel()
        self.tree.setModel(self.model)
        self.populate_tree()
        layout.addWidget(self.tree)
        
        btn_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        btn_box.accepted.connect(self.on_accept)
        btn_box.rejected.connect(self.reject)
        layout.addWidget(btn_box)
        
        self.selected_book_name = None
        self.selected_item_id = None
        self.selected_item_name = None
    # ▼▼▼ filter_treeメソッドを更新 ▼▼▼
    def filter_tree(self, text):
        """'ID:番号' 形式の検索に対応したフィルタリングを行う"""
        search_term = text.lower().strip()
        root = self.model.invisibleRootItem()
        is_id_prefix_search = False
        search_id_value = ""
        # 'id:' で始まっているかチェック (大文字小文字は無視)
        if search_term.startswith("id:"):
            parts = search_term.split(':', 1)
            # ':' の後に数字が続く場合のみID検索とみなす
            if len(parts) > 1 and parts[1].strip().isdigit():
                is_id_prefix_search = True
                search_id_value = parts[1].strip()
        for i in range(root.rowCount()):
            book_item = root.child(i)
            book_has_match = False
            for j in range(book_item.rowCount()):
                note_item = book_item.child(j)
                
                item_data = note_item.data(Qt.ItemDataRole.UserRole)
                note_title = note_item.text().lower()
                note_id_str = str(item_data.get("note_id", "")) if item_data else ""
                is_match = False
                if is_id_prefix_search:
                    # 'id:' 検索の場合は、IDのみを比較
                    is_match = (note_id_str == search_id_value)
                else:
                    # 通常検索の場合は、タイトルとIDの両方を比較
                    is_match = (search_term in note_title) or (search_term == note_id_str)
                self.tree.setRowHidden(j, book_item.index(), not is_match)
                if is_match:
                    book_has_match = True
            is_book_visible = not search_term or book_has_match
            self.tree.setRowHidden(i, QModelIndex(), not is_book_visible)
            if is_book_visible and search_term:
                self.tree.expand(book_item.index())
            else:
                self.tree.collapse(book_item.index())
    def populate_tree(self):
        self.model.clear()
        books = self.db_manager.get_all_book_names()
        # ▼▼▼ 変更点1: settings_manager からアイコンと色の設定を取得します ▼▼▼
        book_icons = self.settings_manager.get('book_icons', {})
        book_colors = self.settings_manager.get('book_colors', {})
        for book_name in books:
            # ▼▼▼ 変更点2: アイコンをテキストとして設定します ▼▼▼
            icon_char = book_icons.get(book_name, '🗒️')
            book_item = QStandardItem(f"{icon_char} {book_name}")
            book_item.setIcon(QIcon()) # デフォルトのフォルダアイコンを消去
            # ▼▼▼ 変更点3: 背景色を設定します ▼▼▼
            book_color_name = book_colors.get(book_name)
            item_color_brush = None
            if book_color_name:
                color = QColor(book_color_name)
                color.setAlpha(40)
                item_color_brush = QBrush(color)
                book_item.setBackground(item_color_brush)
            book_item.setData({"type": "book", "book_name": book_name}, Qt.ItemDataRole.UserRole)
            book_item.setEditable(False)
            
            note_model = self.db_manager.get_note_model(book_name)
            if note_model:
                cur = note_model.conn.cursor()
                cur.execute("SELECT id, title FROM notes WHERE is_folder = 0 AND is_deleted = 0 ORDER BY title ASC")
                notes = cur.fetchall()
                for note_id, title in notes:
                    child_q_item = QStandardItem(title)
                    child_q_item.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon))
                    # ▼▼▼ 変更点4: ノートアイテムにも同じ背景色を適用します ▼▼▼
                    if item_color_brush:
                        child_q_item.setBackground(item_color_brush)
                    child_q_item.setData({
                        "type": "note",
                        "book_name": book_name,
                        "note_id": note_id,
                        "note_name": title
                    }, Qt.ItemDataRole.UserRole)
                    child_q_item.setEditable(False)
                    book_item.appendRow(child_q_item)
            
            self.model.appendRow(book_item)
    def on_accept(self):
        indexes = self.tree.selectedIndexes()
        if not indexes:
            QMessageBox.warning(self, "エラー", "ノートを選択してください。")
            return
            
        item_data = self.model.itemFromIndex(indexes[0]).data(Qt.ItemDataRole.UserRole)
        if not item_data or item_data.get("type") != "note":
            QMessageBox.warning(self, "エラー", "ノートを選択してください。")
            return
        self.selected_book_name = item_data["book_name"]
        self.selected_item_id = item_data["note_id"]
        self.selected_item_name = item_data["note_name"]
        self.accept()
    def get_selected_item_info(self):
        return self.selected_book_name, self.selected_item_id, self.selected_item_name
class SelectDestinationBookDialog(QDialog):
    def __init__(self, all_books, source_book, parent=None):
        super().__init__(parent)
        self.setWindowTitle("移動先のブックを選択")
        self.selected_book = None
        
        layout = QVBoxLayout(self)
        layout.addWidget(QLabel(f"<b>移動元:</b> {source_book}<br><br>移動先のブックを選択してください:"))
        
        self.book_list = QListWidget()
        destinations = [book for book in all_books if book != source_book]
        if not destinations:
            layout.addWidget(QLabel("移動可能な他のブックがありません。"))
        else:
            self.book_list.addItems(destinations)
            self.book_list.setCurrentRow(0)
        
        layout.addWidget(self.book_list)
        btn_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        btn_box.accepted.connect(self.accept)
        btn_box.rejected.connect(self.reject)
        
        if not destinations:
            btn_box.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False)
            
        layout.addWidget(btn_box)
    def accept(self):
        if self.book_list.currentItem():
            self.selected_book = self.book_list.currentItem().text()
        super().accept()
    def get_selected_book(self):
        return self.selected_book
class SettingsDialog(QDialog):
    rebuildLinksRequested = pyqtSignal()
    rebuildTagsRequested = pyqtSignal()
    dbConfigChanged = pyqtSignal()
    def __init__(self, settings_manager, parent=None):
        super().__init__(parent)
        self.settings_manager = settings_manager
        self.settings = self.settings_manager.settings.copy()
        
        self.setWindowTitle("環境設定")
        self.setMinimumWidth(600)
        
        main_layout = QVBoxLayout(self)
        self.tabs = QTabWidget()
        main_layout.addWidget(self.tabs)
        
        # ▼▼▼ 修正点: タブ生成メソッドの呼び出し方を新しい構成に変更 ▼▼▼
        self.init_appearance_tab()
        self.init_editor_preview_tab()
        self.init_database_tab()       # 新しい「データベース」タブ
        self.init_maintenance_tab()    # 新しい「メンテナンス」タブ
        self.init_application_tab()    # 新しい「アプリケーション」タブ
        
        btn_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        btn_box.accepted.connect(self.accept)
        btn_box.rejected.connect(self.reject)
        main_layout.addWidget(btn_box)
        
        self.load_settings()
    def _create_icon_combo(self, selected_icon="🗒️"): # デフォルトを「なし」の絵文字に変更
        combo = QComboBox()
        # ▼▼▼ 変更点: アイコンの選択肢を固定します ▼▼▼
        icons = ["🗒️", "📕", "📗", "📘", "📙"]
        combo.addItems(icons)
        if selected_icon in icons:
            combo.setCurrentText(selected_icon)
        else:
            combo.setCurrentText("🗒️") # 不明なアイコンの場合はデフォルトを設定
        return combo
    
# SettingsDialog クラス内
    def init_appearance_tab(self):
        tab = QWidget(); form = QFormLayout(tab)
        self.theme_combo = QComboBox(); self.theme_combo.addItems(["Dark", "Gray", "Light"]); form.addRow("GUIテーマ:", self.theme_combo)
        self.app_font_combo = QFontComboBox(); self.app_font_size_spin = QSpinBox(); self.app_font_size_spin.setRange(8, 20)
        form.addRow("GUI フォント:", self.app_font_combo); form.addRow("GUI フォントサイズ:", self.app_font_size_spin)
        
        # ▼▼▼ ここからが追加部分です ▼▼▼
        separator = QFrame(); separator.setFrameShape(QFrame.Shape.HLine); separator.setFrameShadow(QFrame.Shadow.Sunken); form.addRow(separator)
        form.addRow(QLabel("<b>表示設定</b>"))
        
        self.sort_by_title_cb = QCheckBox("ノート一覧を更新日時順から名称順に変更する")
        form.addRow("", self.sort_by_title_cb)
        # ▲▲▲ 追加ここまで ▲▲▲
        self.hide_prefix_cb = QCheckBox("検索で接頭辞 [BOOK名] を非表示にする")
        form.addRow("", self.hide_prefix_cb)
        self.tabs.addTab(tab, "外観")
    def init_editor_preview_tab(self):
        tab = QWidget(); form = QFormLayout(tab)
        form.addRow(QLabel("<b>エディタ</b>"))
        self.editor_font_combo = QFontComboBox(); self.editor_font_size_spin = QSpinBox(); self.editor_font_size_spin.setRange(8, 30)
        form.addRow("フォント:", self.editor_font_combo); form.addRow("フォントサイズ:", self.editor_font_size_spin)
        self.show_optional_cb = QCheckBox("オプション装飾ツールバーを表示する")
        form.addRow("", self.show_optional_cb)
        self.show_advanced_cb = QCheckBox("高度な装飾ツールバーを表示する")
        form.addRow("", self.show_advanced_cb)
        separator = QFrame(); separator.setFrameShape(QFrame.Shape.HLine); separator.setFrameShadow(QFrame.Shadow.Sunken); form.addRow(separator)
        form.addRow(QLabel("<b>プレビュー</b>"))
        self.preview_theme_combo = QComboBox(); self.preview_theme_combo.addItems(["Dark", "Gray", "Light"]); form.addRow("テーマ:", self.preview_theme_combo)
        css_layout = QHBoxLayout(); self.css_path_edit = QLineEdit(); btn_css = QPushButton("参照…"); btn_css.clicked.connect(self.browse_css)
        css_layout.addWidget(self.css_path_edit); css_layout.addWidget(btn_css); form.addRow("カスタム CSS:", css_layout)
        
        # ▼▼▼ 変更点: ここにアドレスバーのチェックボックスを追加します ▼▼▼
        self.show_address_bar_cb = QCheckBox("プレビューにアドレスバーを表示する")
        form.addRow("", self.show_address_bar_cb) # ラベルなしでチェックボックスのみ配置
        separator2 = QFrame(); separator2.setFrameShape(QFrame.Shape.HLine); separator2.setFrameShadow(QFrame.Shadow.Sunken); form.addRow(separator2)
        form.addRow(QLabel("<b>画像貼り付け</b>"))
        self.image_folder_edit = QLineEdit()
        btn_browse_img = QPushButton("参照…")
        btn_browse_img.clicked.connect(self.browse_image_folder)
        img_layout = QHBoxLayout()
        img_layout.addWidget(self.image_folder_edit)
        img_layout.addWidget(btn_browse_img)
        form.addRow("クリップボード画像の保存先:", img_layout)
        self.tabs.addTab(tab, "エディタとプレビュー")
    def init_files_history_tab(self):
        tab = QWidget(); main_layout = QVBoxLayout(tab)
        
        main_layout.addWidget(QLabel("<b>データベース ブック</b>"))
        
        self.db_table = QTableWidget(); self.db_table.setColumnCount(4)
        # ▼▼▼ 変更点2: 「テーマカラー」のヘッダーを削除します ▼▼▼
        self.db_table.setHorizontalHeaderLabels(["読込", "アイコン", "ブック名", "ファイルパス"])
        self.db_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) # 読込
        self.db_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # アイコン
        # ▼▼▼ 変更点3: 列のインデックスを調整します ▼▼▼
        self.db_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive)    # ブック名
        self.db_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)         # パス
        self.db_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
        self.db_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
        self.db_table.itemChanged.connect(self.on_db_table_item_changed)
        self.db_table.cellDoubleClicked.connect(self.on_db_cell_double_clicked)
        main_layout.addWidget(self.db_table)
        db_btn_layout = QHBoxLayout()
        add_db_btn = QPushButton("追加"); add_db_btn.clicked.connect(self.add_db_row)
        remove_db_btn = QPushButton("削除"); remove_db_btn.clicked.connect(self.remove_db_row)
        browse_db_btn = QPushButton("フォルダを開く"); browse_db_btn.clicked.connect(self.open_db_folder)
        db_btn_layout.addWidget(add_db_btn); db_btn_layout.addWidget(remove_db_btn); db_btn_layout.addWidget(browse_db_btn)
        db_btn_layout.addStretch()
        move_up_btn = QPushButton("↑ 上へ"); move_up_btn.clicked.connect(self.move_db_row_up)
        move_down_btn = QPushButton("↓ 下へ"); move_down_btn.clicked.connect(self.move_db_row_down)
        db_btn_layout.addWidget(move_up_btn)
        db_btn_layout.addWidget(move_down_btn)
        main_layout.addLayout(db_btn_layout)
        form = QFormLayout()
        btn_rebuild_links = QPushButton("全ブックのリンクインデックスを再構築"); btn_rebuild_links.clicked.connect(self.rebuildLinksRequested.emit)
        btn_rebuild_tags = QPushButton("全ブックのタグインデックスを再構築"); btn_rebuild_tags.clicked.connect(self.rebuildTagsRequested.emit)
        form.addRow(btn_rebuild_links, btn_rebuild_tags)
        
        separator1 = QFrame(); separator1.setFrameShape(QFrame.Shape.HLine); separator1.setFrameShadow(QFrame.Shadow.Sunken); form.addRow(separator1)
        form.addRow(QLabel("<b>アプリケーション</b>")); btn_open_settings_folder = QPushButton("設定フォルダを開く"); btn_open_settings_folder.clicked.connect(self.open_settings_folder); form.addRow("設定ファイル:", btn_open_settings_folder)
        separator2 = QFrame(); separator2.setFrameShape(QFrame.Shape.HLine); separator2.setFrameShadow(QFrame.Shadow.Sunken); form.addRow(separator2)
        form.addRow(QLabel("<b>動作設定</b>")); 
        self.search_history_spin = QSpinBox(); self.search_history_spin.setRange(0, 100); form.addRow("検索履歴の保存件数:", self.search_history_spin)
        self.lock_timeout_spin = QSpinBox(); self.lock_timeout_spin.setRange(1, 60); self.lock_timeout_spin.setSuffix(" 秒"); form.addRow("DBロック待機時間:", self.lock_timeout_spin)
        self.db_check_interval_spin = QSpinBox()
        self.db_check_interval_spin.setRange(5, 600) # 5秒から10分
        self.db_check_interval_spin.setSuffix(" 秒")
        form.addRow("DB外部更新のチェック間隔:", self.db_check_interval_spin)
        self.lock_check_interval_spin = QSpinBox()
        self.lock_check_interval_spin.setRange(5, 600) # 5秒から10分
        self.lock_check_interval_spin.setSuffix(" 秒")
        form.addRow("ロックファイルの所有権チェック間隔:", self.lock_check_interval_spin)
        self.show_trash_view_cb = QCheckBox("ナビゲーションバーに「ゴミ箱」を表示"); form.addRow("", self.show_trash_view_cb)
        
        self.show_zero_link_notes_cb = QCheckBox("リンク数0のメモをリンクビューに表示する")
        form.addRow("", self.show_zero_link_notes_cb)
        
        separator3 = QFrame(); separator3.setFrameShape(QFrame.Shape.HLine); separator3.setFrameShadow(QFrame.Shadow.Sunken); form.addRow(separator3)
        form.addRow(QLabel("<b>手動ロック設定</b>"))
        self.lockout_spin = QSpinBox()
        self.lockout_spin.setRange(5, 3600)
        self.lockout_spin.setSuffix(" 秒")
        form.addRow("ロックアウト時間:", self.lockout_spin)
        self.lockin_spin = QSpinBox()
        self.lockin_spin.setRange(5, 3600)
        self.lockin_spin.setSuffix(" 秒")
        form.addRow("ロックイン時間:", self.lockin_spin)
        main_layout.addLayout(form)
        self.tabs.addTab(tab, "ファイルと履歴")
    def on_db_cell_double_clicked(self, row, column):
        """
        テーブルのセルがダブルクリックされたときに呼び出される
        """
        # ファイルパスの列 (インデックス 2) でなければ何もしない
        if column != 3:
            return
        # 現在のセルから既存のパスを取得し、ダイアログの初期ディレクトリとして使用
        current_item = self.db_table.item(row, column)
        start_dir = ""
        if current_item and current_item.text():
            # 既存のパスからディレクトリ部分を取得
            start_dir = os.path.dirname(current_item.text())
        # ファイル選択ダイアログを開く
        path, _ = QFileDialog.getOpenFileName(
            self,
            "データベースファイルを選択",
            start_dir,  # 初期ディレクトリを指定
            "Database Files (*.db);;All Files (*)"
        )
        # ユーザーがファイルを選択した場合
        if path:
            # 選択されたパスでセルを更新
            # setItemはitemChangedシグナルを自動で発行するため、
            # on_db_table_item_changed が呼ばれてブック名も自動更新される
            self.db_table.setItem(row, 3, QTableWidgetItem(path))
    
    def on_db_table_item_changed(self, item):
        if item.column() != 3:
            return
        row = item.row()
        path = item.text()
        
        book_name_item = self.db_table.item(row, 2)
        if not path or (book_name_item and book_name_item.text()):
            return
        
        book_name = os.path.splitext(os.path.basename(path))[0]
        
        self.db_table.blockSignals(True)
        self.db_table.setItem(row, 2, QTableWidgetItem(book_name))
        self.db_table.blockSignals(False)
    def open_settings_folder(self):
        folder = os.path.dirname(self.settings_manager.settings_path)
        if os.path.isdir(folder): QDesktopServices.openUrl(QUrl.fromLocalFile(folder))
        else: QMessageBox.warning(self, "エラー", f"フォルダが見つかりません:\n{folder}")
    def browse_css(self):
        path, _ = QFileDialog.getOpenFileName(self, "CSS ファイルを選択", "", "CSS Files (*.css)")
        if path: self.css_path_edit.setText(path)
            
    
    def load_settings(self):
        self.theme_combo.setCurrentText(self.settings.get('gui_theme', 'Dark'))
        self.app_font_combo.setCurrentFont(QFont(self.settings.get('app_font_family', QApplication.font().family())))
        self.app_font_size_spin.setValue(self.settings.get('app_font_size', 12))
        self.sort_by_title_cb.setChecked(self.settings.get('sort_by_title', False))
        self.editor_font_combo.setCurrentFont(QFont(self.settings.get('editor_font_family', 'Courier New')))
        self.editor_font_size_spin.setValue(self.settings.get('editor_font_size', 12))
        self.show_optional_cb.setChecked(self.settings.get('show_optional_toolbar', False))
        self.show_advanced_cb.setChecked(self.settings.get('show_advanced_toolbar', False))
        self.preview_theme_combo.setCurrentText(self.settings.get('preview_theme', 'Dark'))
        self.css_path_edit.setText(self.settings.get('preview_css_path', ''))
        self.show_address_bar_cb.setChecked(self.settings.get('show_address_bar', False))
        self.search_history_spin.setValue(self.settings.get('search_history_limit', 20))
        self.lock_timeout_spin.setValue(self.settings.get('lock_timeout_seconds', 60))
        self.show_trash_view_cb.setChecked(self.settings.get('show_trash_view', False))
        self.show_zero_link_notes_cb.setChecked(self.settings.get('show_zero_link_notes', False))
        self.lockout_spin.setValue(self.settings.get('lockout_duration_seconds', 30))
        self.lockin_spin.setValue(self.settings.get('lockin_duration_seconds', 30))
        self.db_check_interval_spin.setValue(self.settings.get('db_check_interval_seconds', 10))
        self.lock_check_interval_spin.setValue(self.settings.get('lock_check_interval_seconds', 10))
        self.db_table.setRowCount(0)
        db_connections = self.settings.get('db_connections', {})
        book_icons = self.settings.get('book_icons', {})
#        book_colors = self.settings.get('book_colors', {})
        load_on_startup = self.settings.get('load_on_startup', {})
        for name, path in db_connections.items():
            row_pos = self.db_table.rowCount()
            self.db_table.insertRow(row_pos)
            
            # セル 0: チェックボックス
            checkbox_item = QTableWidgetItem()
            checkbox_item.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled)
            is_checked = load_on_startup.get(name, True)
            checkbox_item.setCheckState(Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked)
            self.db_table.setItem(row_pos, 0, checkbox_item)
            
            # セル 1: アイコン
            current_icon = book_icons.get(name, "🗒️") # デフォルトを絵文字に
            self.db_table.setCellWidget(row_pos, 1, self._create_icon_combo(current_icon))
            
            # ▼▼▼ 変更点: テーマカラーのセル配置処理を削除 ▼▼▼
            
            # セル 2: ブック名 (列番号がずれる)
            self.db_table.setItem(row_pos, 2, QTableWidgetItem(name))
            
            # セル 3: ファイルパス (列番号がずれる)
            self.db_table.setItem(row_pos, 3, QTableWidgetItem(path))
        self.hide_prefix_cb.setChecked(self.settings.get('hide_book_prefix_in_views', False))
        self.image_folder_edit.setText(self.settings.get('image_paste_folder', '')) 
    def accept(self):
        self.settings['gui_theme'] = self.theme_combo.currentText()
        self.settings['app_font_family'] = self.app_font_combo.currentFont().family()
        self.settings['app_font_size'] = self.app_font_size_spin.value()
        self.settings['sort_by_title'] = self.sort_by_title_cb.isChecked()
        self.settings['editor_font_family'] = self.editor_font_combo.currentFont().family()
        self.settings['editor_font_size'] = self.editor_font_size_spin.value()
        self.settings['show_optional_toolbar'] = self.show_optional_cb.isChecked()
        self.settings['show_advanced_toolbar'] = self.show_advanced_cb.isChecked()
        self.settings['preview_theme'] = self.preview_theme_combo.currentText()
        self.settings['preview_css_path'] = self.css_path_edit.text()
        self.settings['show_address_bar'] = self.show_address_bar_cb.isChecked()
        self.settings['search_history_limit'] = self.search_history_spin.value()
        self.settings['lock_timeout_seconds'] = self.lock_timeout_spin.value()
        self.settings['show_trash_view'] = self.show_trash_view_cb.isChecked()
        self.settings['show_zero_link_notes'] = self.show_zero_link_notes_cb.isChecked()
        self.settings['lockout_duration_seconds'] = self.lockout_spin.value()
        self.settings['lockin_duration_seconds'] = self.lockin_spin.value()
        self.settings['hide_book_prefix_in_views'] = self.hide_prefix_cb.isChecked()
        self.settings['db_check_interval_seconds'] = self.db_check_interval_spin.value()
        self.settings['lock_check_interval_seconds'] = self.lock_check_interval_spin.value()
        self.settings['image_paste_folder'] = self.image_folder_edit.text()
        icon_color_map = {
            "🗒️": "なし",
            "📕": "red",
            "📗": "green",
            "📘": "blue",
            "📙": "gold"
        }
        new_connections = collections.OrderedDict()
        new_book_icons = {}
        new_book_colors = {}
        new_load_on_startup = {}
        for row in range(self.db_table.rowCount()):
            checkbox_item = self.db_table.item(row, 0)
            icon_combo = self.db_table.cellWidget(row, 1)
            # ▼▼▼ 変更点2: カラーコンボボックスの取得処理を削除し、列インデックスを修正 ▼▼▼
            name_item = self.db_table.item(row, 2)
            path_item = self.db_table.item(row, 3)
            
            if name_item and path_item and name_item.text() and path_item.text():
                book_name = name_item.text().strip() # .strip()で前後の空白を除去
                # ▼▼▼ 修正点: ブック名が空でないかチェック ▼▼▼
                if not book_name:
                    QMessageBox.warning(self, "入力エラー", f"{row + 1}行目のブック名が空です。")
                    return # 保存処理を中断
                
                new_connections[book_name] = path_item.text()
                
                if checkbox_item:
                    is_checked = checkbox_item.checkState() == Qt.CheckState.Checked
                    new_load_on_startup[book_name] = is_checked
                # ▼▼▼ 変更点3: アイコンから色を自動決定するロジック ▼▼▼
                if icon_combo:
                    selected_icon = icon_combo.currentText()
                    # アイコン設定を保存 (常に保存)
                    new_book_icons[book_name] = selected_icon
                    
                    # 対応する色を取得
                    selected_color = icon_color_map.get(selected_icon, "なし")
                    
                    # 色が「なし」でない場合のみ、色の設定を保存
                    if selected_color != "なし":
                        new_book_colors[book_name] = selected_color
        
        self.settings['db_connections'] = new_connections
        self.settings['book_icons'] = new_book_icons
        self.settings['book_colors'] = new_book_colors
        self.settings['load_on_startup'] = new_load_on_startup
        super().accept()
    
    def add_db_row(self):
        row_pos = self.db_table.rowCount()
        self.db_table.insertRow(row_pos)
        # 1. チェックボックス用のテーブルアイテムを作成します
        checkbox_item = QTableWidgetItem()
        # 2. アイテムがチェック可能であることを設定します
        checkbox_item.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled)
        # 3. 新しく追加された項目なので、デフォルトでチェックされた状態にします
        checkbox_item.setCheckState(Qt.CheckState.Checked)
        # 4. 作成したアイテムを、新しい行の0列目にセットします
        self.db_table.setItem(row_pos, 0, checkbox_item)
        # 5. (既存の処理) アイコンのコンボボックスを、新しい行の1列目にセットします
        self.db_table.setCellWidget(row_pos, 1, self._create_icon_combo())
    def remove_db_row(self):
        current_row = self.db_table.currentRow()
        if current_row >= 0: self.db_table.removeRow(current_row)
    def open_db_folder(self):
        current_row = self.db_table.currentRow()
        if current_row < 0:
            QMessageBox.warning(self, "エラー", "フォルダを開くブックの行を選択してください。")
            return
        
        path_item = self.db_table.item(current_row, 3) # 列インデックスは3で正しい
        if not path_item or not path_item.text():
            QMessageBox.warning(self, "エラー", "このブックには有効なファイルパスが設定されていません。")
            return
            
        db_path = path_item.text()
        folder = os.path.dirname(db_path)
        
        if os.path.isdir(folder):
            # --- ▼▼▼ ここからが変更点です ▼▼▼ ---
            # OSを判定し、macOS(darwin)の場合だけ特別な処理を行う
            if sys.platform == "darwin":
                try:
                    # macOSの 'open' コマンドを直接呼び出してFinderでフォルダを開く
                    # この方法は特殊なパスに非常に強い
                    subprocess.run(["open", folder], check=True)
                except (subprocess.CalledProcessError, FileNotFoundError) as e:
                    QMessageBox.critical(self, "エラー", f"Finderでフォルダを開けませんでした:\n{e}")
            else:
                # macOS以外(Windows, Linuxなど)では、従来通りの安全な方法を使う
                QDesktopServices.openUrl(QUrl.fromLocalFile(folder))
            # --- ▲▲▲ 変更ここまで ▲▲▲ ---
        else:
            QMessageBox.warning(self, "エラー", f"フォルダが見つかりません:\n{folder}")
    def move_db_row_up(self):
        row = self.db_table.currentRow()
        if row > 0:
            target_row = row - 1
            
            # ▼▼▼ 4列すべてのデータを正しく取得するように全面的に修正 ▼▼▼
            # チェックボックスの状態
            check_state_row = self.db_table.item(row, 0).checkState()
            check_state_target = self.db_table.item(target_row, 0).checkState()
            
            # アイコン、ブック名、パス
            icon_combo_row = self.db_table.cellWidget(row, 1)
            name_item_row = self.db_table.item(row, 2).text()
            path_item_row = self.db_table.item(row, 3).text()
            
            icon_combo_target = self.db_table.cellWidget(target_row, 1)
            name_item_target = self.db_table.item(target_row, 2).text()
            path_item_target = self.db_table.item(target_row, 3).text()
            # データを入れ替え
            self.db_table.item(row, 0).setCheckState(check_state_target)
            icon_combo_row.setCurrentIndex(icon_combo_target.currentIndex())
            self.db_table.item(row, 2).setText(name_item_target)
            self.db_table.item(row, 3).setText(path_item_target)
            
            self.db_table.item(target_row, 0).setCheckState(check_state_row)
            icon_combo_target.setCurrentIndex(icon_combo_row.currentIndex())
            self.db_table.item(target_row, 2).setText(name_item_row)
            self.db_table.item(target_row, 3).setText(path_item_row)
            
            self.db_table.selectRow(target_row)
    def move_db_row_down(self):
        row = self.db_table.currentRow()
        if row < self.db_table.rowCount() - 1 and row != -1:
            target_row = row + 1
            # ▼▼▼ 4列すべてのデータを正しく取得するように全面的に修正 ▼▼▼
            # チェックボックスの状態
            check_state_row = self.db_table.item(row, 0).checkState()
            check_state_target = self.db_table.item(target_row, 0).checkState()
            
            # アイコン、ブック名、パス
            icon_combo_row = self.db_table.cellWidget(row, 1)
            name_item_row = self.db_table.item(row, 2).text()
            path_item_row = self.db_table.item(row, 3).text()
            
            icon_combo_target = self.db_table.cellWidget(target_row, 1)
            name_item_target = self.db_table.item(target_row, 2).text()
            path_item_target = self.db_table.item(target_row, 3).text()
            # データを入れ替え
            self.db_table.item(row, 0).setCheckState(check_state_target)
            icon_combo_row.setCurrentIndex(icon_combo_target.currentIndex())
            self.db_table.item(row, 2).setText(name_item_target)
            self.db_table.item(row, 3).setText(path_item_target)
            
            self.db_table.item(target_row, 0).setCheckState(check_state_row)
            icon_combo_target.setCurrentIndex(icon_combo_row.currentIndex())
            self.db_table.item(target_row, 2).setText(name_item_row)
            self.db_table.item(target_row, 3).setText(path_item_row)
            
            self.db_table.selectRow(target_row)
    def get_settings(self): return self.settings
    
    def init_database_tab(self):
        """「データベース」タブのUIを作成する"""
        tab = QWidget()
        main_layout = QVBoxLayout(tab)
        
        # --- ここからが、元の init_files_history_tab の前半部分 ---
        main_layout.addWidget(QLabel("<b>データベース ブックの管理</b>"))
        
        self.db_table = QTableWidget()
        self.db_table.setColumnCount(4)
        self.db_table.setHorizontalHeaderLabels(["読込", "アイコン", "ブック名", "ファイルパス"])
        self.db_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
        self.db_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
        self.db_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive)
        self.db_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
        self.db_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
        self.db_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
        self.db_table.itemChanged.connect(self.on_db_table_item_changed)
        self.db_table.cellDoubleClicked.connect(self.on_db_cell_double_clicked)
        main_layout.addWidget(self.db_table)
        db_btn_layout = QHBoxLayout()
        add_db_btn = QPushButton("追加"); add_db_btn.clicked.connect(self.add_db_row)
        remove_db_btn = QPushButton("削除"); remove_db_btn.clicked.connect(self.remove_db_row)
        browse_db_btn = QPushButton("フォルダを開く"); browse_db_btn.clicked.connect(self.open_db_folder)
        db_btn_layout.addWidget(add_db_btn); db_btn_layout.addWidget(remove_db_btn); db_btn_layout.addWidget(browse_db_btn)
        db_btn_layout.addStretch()
        move_up_btn = QPushButton("↑ 上へ"); move_up_btn.clicked.connect(self.move_db_row_up)
        move_down_btn = QPushButton("↓ 下へ"); move_down_btn.clicked.connect(self.move_db_row_down)
        db_btn_layout.addWidget(move_up_btn)
        db_btn_layout.addWidget(move_down_btn)
        main_layout.addLayout(db_btn_layout)
        # --- ここまで ---
        
        self.tabs.addTab(tab, "データベース")
    def init_maintenance_tab(self):
        """「メンテナンス」タブのUIを作成する"""
        tab = QWidget()
        form = QFormLayout(tab)
        # --- ここからが、元の init_files_history_tab の中盤部分 ---
        form.addRow(QLabel("<b>インデックスのメンテナンス</b>"))
        btn_rebuild_links = QPushButton("全ブックのリンクインデックスを再構築")
        btn_rebuild_links.clicked.connect(self.rebuildLinksRequested.emit)
        
        btn_rebuild_tags = QPushButton("全ブックのタグインデックスを再構築")
        btn_rebuild_tags.clicked.connect(self.rebuildTagsRequested.emit)
        
        form.addRow(btn_rebuild_links, btn_rebuild_tags)
        
        separator = QFrame(); separator.setFrameShape(QFrame.Shape.HLine); separator.setFrameShadow(QFrame.Shadow.Sunken); form.addRow(separator)
        
        form.addRow(QLabel("<b>設定ファイル</b>"))
        btn_open_settings_folder = QPushButton("設定フォルダを開く")
        btn_open_settings_folder.clicked.connect(self.open_settings_folder)
        form.addRow("設定フォルダ:", btn_open_settings_folder)
        # --- ここまで ---
        self.tabs.addTab(tab, "メンテナンス")
    def init_application_tab(self):
        """「アプリケーション」タブのUIを作成する"""
        tab = QWidget()
        form = QFormLayout(tab)
        
        form.addRow(QLabel("<b>動作設定</b>"))
        self.search_history_spin = QSpinBox(); self.search_history_spin.setRange(0, 100); form.addRow("検索履歴の保存件数:", self.search_history_spin)
        self.lock_timeout_spin = QSpinBox(); self.lock_timeout_spin.setRange(1, 60); self.lock_timeout_spin.setSuffix(" 秒"); form.addRow("DBロック待機時間:", self.lock_timeout_spin)
        
        self.db_check_interval_spin = QSpinBox()
        self.db_check_interval_spin.setRange(5, 600) # 5秒から10分
        self.db_check_interval_spin.setSuffix(" 秒")
        form.addRow("DB外部更新のチェック間隔:", self.db_check_interval_spin)
        self.lock_check_interval_spin = QSpinBox()
        self.lock_check_interval_spin.setRange(5, 600) # 5秒から10分
        self.lock_check_interval_spin.setSuffix(" 秒")
        form.addRow("ロックファイルの所有権チェック間隔:", self.lock_check_interval_spin)
        # ▼▼▼ 修正点: このブロックが抜けていました ▼▼▼
        self.lock_check_interval_spin = QSpinBox()
        self.lock_check_interval_spin.setRange(5, 600) # 5秒から10分
        self.lock_check_interval_spin.setSuffix(" 秒")
        form.addRow("ロックファイルの所有権チェック間隔:", self.lock_check_interval_spin)
        
        self.show_trash_view_cb = QCheckBox("ナビゲーションバーに「ゴミ箱」を表示"); form.addRow("", self.show_trash_view_cb)
        
        self.show_zero_link_notes_cb = QCheckBox("リンク数0のメモをリンクビューに表示する")
        form.addRow("", self.show_zero_link_notes_cb)
        
        separator = QFrame(); separator.setFrameShape(QFrame.Shape.HLine); separator.setFrameShadow(QFrame.Shadow.Sunken); form.addRow(separator)
        
        form.addRow(QLabel("<b>手動ロック設定</b>"))
        self.lockout_spin = QSpinBox()
        self.lockout_spin.setRange(5, 3600)
        self.lockout_spin.setSuffix(" 秒")
        form.addRow("ロックアウト時間:", self.lockout_spin)
        self.lockin_spin = QSpinBox()
        self.lockin_spin.setRange(5, 3600)
        self.lockin_spin.setSuffix(" 秒")
        form.addRow("ロックイン時間:", self.lockin_spin)
        self.tabs.addTab(tab, "アプリケーション")
# SettingsDialog クラス内 (init_... メソッドの外)
    def browse_image_folder(self):
        """画像保存フォルダを選択するダイアログを開く"""
        current_path = self.image_folder_edit.text()
        # QStandardPathsを使って、デフォルトでユーザーのピクチャフォルダを開く
        start_dir = current_path or QStandardPaths.standardLocations(QStandardPaths.StandardLocation.PicturesLocation)[0]
        
        path = QFileDialog.getExistingDirectory(self, "画像保存フォルダを選択", start_dir)
        if path:
            self.image_folder_edit.setText(path)
# =================================================================
# 4. DATA MODEL
# =================================================================
class TreeItem:
    def __init__(self, name, db_id=None, is_folder=False, parent=None, updated_at=None, deleted_at=None, book_name=None):
        self.name = name; self.db_id = db_id; self.is_folder = is_folder; self.parent = parent
        self.children = []; self._visible = True; self.updated_at = updated_at
        self.deleted_at = deleted_at
        self.book_name = book_name
        
    def append_child(self, item): self.children.append(item)
    def child(self, row): return self.children[row] if 0 <= row < len(self.children) else None
    def child_count(self): return len(self.children)
    def row(self): return self.parent.children.index(self) if self.parent else 0
    def insert_child(self, position, item): self.children.insert(position, item)
    def remove_child(self, target):
        if isinstance(target, int):
            if 0 <= target < len(self.children): return self.children.pop(target)
        elif isinstance(target, TreeItem) and target in self.children:
            self.children.remove(target); return target
        return None
class NoteTreeModel(QAbstractItemModel):
    modelReloaded = pyqtSignal()
    
    def __init__(self, db_path, book_name, settings_manager, book_name_to_id_map, parent=None):
        super().__init__(parent)
        self.db_path = db_path
        self.book_name = book_name
        self.settings_manager = settings_manager
        self.book_name_to_id_map_for_migration = book_name_to_id_map
        self.conn = None
        self.root_item = TreeItem("Root", is_folder=True, book_name=book_name)
        
        # ▼▼▼ 修正点: UI関連の誤ったコードを全て削除し、DB設定処理のみを残します ▼▼▼
        self.setup_database()
        if self.conn:
            self.load_from_db()
    @contextmanager
    def db_transaction(self):
        cursor = self.conn.cursor()
        try:
            cursor.execute("BEGIN EXCLUSIVE TRANSACTION"); yield cursor; self.conn.commit()
        except sqlite3.Error as e:
            print(f"DB transaction failed in '{self.book_name}', rolling back. Error: {e}")
            self.conn.rollback(); raise
    def setup_database(self):
        if self.conn: return
        try:
            sqlite3.register_adapter(datetime, lambda ts: ts.isoformat())
            sqlite3.register_converter("DATETIME", lambda ts_str: datetime.fromisoformat(ts_str.decode('utf-8')))
            db_dir = os.path.dirname(self.db_path)
            if db_dir and not os.path.exists(db_dir): os.makedirs(db_dir, exist_ok=True)
            
            # 1. データベースに接続し、self.conn を設定
            self.conn = sqlite3.connect(self.db_path, timeout=self.settings_manager.get('lock_timeout_seconds', 10), detect_types=sqlite3.PARSE_DECLTYPES)
            
            # 2. コンテキストマネージャを使わず、直接カーソルを取得して手動でトランザクションを管理
            cur = self.conn.cursor()
            cur.execute('PRAGMA journal_mode=DELETE;')
            cur.execute('BEGIN') # トランザクション開始
            
            cur.execute('PRAGMA foreign_keys = ON;')
            cur.execute('CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER, title TEXT NOT NULL, content TEXT, is_folder BOOLEAN NOT NULL, display_order INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (parent_id) REFERENCES notes(id) ON DELETE CASCADE)')
            cur.execute('CREATE TABLE IF NOT EXISTS links (source_note_id INTEGER NOT NULL, target_note_id INTEGER NOT NULL, target_book_id TEXT, FOREIGN KEY (source_note_id) REFERENCES notes(id) ON DELETE CASCADE, PRIMARY KEY (source_note_id, target_note_id, target_book_id))')
            cur.execute('CREATE TABLE IF NOT EXISTS tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE)')
            cur.execute('CREATE TABLE IF NOT EXISTS note_tags (note_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (note_id, tag_id))')
            cur.execute('CREATE TABLE IF NOT EXISTS notes_history (history_id INTEGER PRIMARY KEY AUTOINCREMENT, note_id INTEGER NOT NULL, title TEXT, content TEXT, saved_at DATETIME NOT NULL, comment TEXT, FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE)')
            
            # スキーママイグレーションを呼び出す (カーソルを引数として渡す)
            self._migrate_db_for_recycle_bin(cur)
            self._migrate_db_for_user_tracking(cur)
            self._migrate_db_for_book_id_links(cur, self.book_name_to_id_map_for_migration)
            self._migrate_db_for_edit_count(cur)
            self.conn.commit() # 全ての処理が成功したらコミット
        except sqlite3.Error as e:
            # エラーが発生した場合はロールバック
            if self.conn:
                self.conn.rollback()
            QMessageBox.critical(None, f"データベースエラー [{self.book_name}]", f"DBを開けませんでした: {e}\nパス: {self.db_path}")
            # 接続を閉じて None に設定
            if self.conn:
                self.conn.close()
            self.conn = None
    def _migrate_db_for_edit_count(self, cursor):
        """notesテーブルに編集回数を記録するedit_countカラムを追加するマイグレーション"""
        try:
            cursor.execute("PRAGMA table_info(notes)")
            columns = [info[1] for info in cursor.fetchall()]
            if 'edit_count' not in columns:
                # 新しいカラムを追加。デフォルトは0で、NULLを許可しない。
                cursor.execute("ALTER TABLE notes ADD COLUMN edit_count INTEGER DEFAULT 0 NOT NULL")
        except sqlite3.Error as e:
            print(f"Edit count migration error: {e}")
    def update_content(self, item_id, title, content):
        if not self.conn: return False
        try:
            current_user = self.settings_manager.parent_controller.current_user
            
            with self.db_transaction() as cur:
                self._add_history_entry(cur, item_id)
                # --- ▼▼▼ UPDATE文を修正 ▼▼▼ ---
                # edit_count = edit_count + 1 を追加して、保存時にカウントアップする
                cur.execute(
                    "UPDATE notes SET title = ?, content = ?, updated_at = ?, last_edited_by = ?, edit_count = edit_count + 1 WHERE id = ?",
                    (title, content, datetime.now(), current_user, item_id)
                )
                # --- ▲▲▲ 修正ここまで ▲▲▲ ---
            return True
        except sqlite3.Error as e:
            print(f"Content update error: {e}")
            return False
        
    def _migrate_db_for_user_tracking(self, cursor):
        try:
            # notes テーブルに created_by と last_edited_by を追加
            cursor.execute("PRAGMA table_info(notes)")
            columns = [info[1] for info in cursor.fetchall()]
            if 'created_by' not in columns:
                cursor.execute("ALTER TABLE notes ADD COLUMN created_by TEXT DEFAULT 'unknown'")
            if 'last_edited_by' not in columns:
                cursor.execute("ALTER TABLE notes ADD COLUMN last_edited_by TEXT DEFAULT 'unknown'")
                
            # notes_history テーブルに edited_by を追加
            cursor.execute("PRAGMA table_info(notes_history)")
            columns = [info[1] for info in cursor.fetchall()]
            if 'edited_by' not in columns:
                cursor.execute("ALTER TABLE notes_history ADD COLUMN edited_by TEXT DEFAULT 'unknown'")
                
        except sqlite3.Error as e:
            print(f"User tracking migration error: {e}")
    def _migrate_db_for_cross_book_links(self, cursor):
        try:
            cursor.execute("PRAGMA table_info(links)")
            columns = [info[1] for info in cursor.fetchall()]
            if 'target_book_name' not in columns:
                cursor.execute('DROP TABLE links')
                cursor.execute('CREATE TABLE links (source_note_id INTEGER NOT NULL, target_note_id INTEGER NOT NULL, target_book_name TEXT, FOREIGN KEY (source_note_id) REFERENCES notes(id) ON DELETE CASCADE, PRIMARY KEY (source_note_id, target_note_id, target_book_name))')
        except sqlite3.Error as e: print(f"Cross-book link migration error: {e}")
    def _migrate_db_for_recycle_bin(self, cursor):
        try:
            cursor.execute("PRAGMA table_info(notes)")
            columns = [info[1] for info in cursor.fetchall()]
            if 'is_deleted' not in columns: cursor.execute("ALTER TABLE notes ADD COLUMN is_deleted BOOLEAN DEFAULT 0")
            if 'deleted_at' not in columns: cursor.execute("ALTER TABLE notes ADD COLUMN deleted_at DATETIME")
        except sqlite3.Error as e: print(f"DB migration error: {e}")
    def close_connection(self):
        if self.conn: self.conn.close(); self.conn = None
            
    def load_from_db(self):
        if not self.conn: return
        self.beginResetModel()
        self.root_item.children.clear()
        cur = self.conn.cursor()
        cur.execute("SELECT id, parent_id, title, is_folder, updated_at FROM notes WHERE is_deleted = 0 ORDER BY display_order ASC")
        rows = cur.fetchall()
        all_items = {None: self.root_item}
        for db_id, parent_id, title, is_folder, updated_at in rows:
            all_items[db_id] = TreeItem(title, db_id, bool(is_folder), updated_at=updated_at, book_name=self.book_name)
        for db_id, parent_id, _, _, _ in rows:
            parent = all_items.get(parent_id, self.root_item)
            child = all_items[db_id]
            child.parent = parent
            parent.append_child(child)
        self.endResetModel()
        self.modelReloaded.emit()
        
    def get_item(self, index: QModelIndex):
        if not index.isValid(): return self.root_item
        item = index.internalPointer()
        return item if item and getattr(item, "_visible", True) else None
        
    def find_item_by_db_id(self, db_id, parent=None):
        if parent is None: parent = self.root_item
        if parent.db_id == db_id: return parent
        for child in parent.children:
            res = self.find_item_by_db_id(db_id, child)
            if res: return res
        return None
    def add_item(self, name, is_folder, content=None):
        if not self.conn: return None
        try:
            # ★★★ 修正箇所: AppControllerからユーザー名を受け取る
            current_user = self.settings_manager.parent_controller.current_user # controller経由で取得
            
            with self.db_transaction() as cur:
                cur.execute("SELECT MAX(display_order) FROM notes WHERE parent_id IS NULL AND is_deleted = 0")
                max_ord = cur.fetchone()[0]
                new_order = (max_ord if max_ord is not None else -1) + 1
                if content is None and not is_folder: content = f"# {name}\n\n"
                
                # ★★★ 修正箇所: created_by と last_edited_by を追加 ★★★
                cur.execute("INSERT INTO notes (parent_id, title, content, is_folder, display_order, created_at, updated_at, created_by, last_edited_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (None, name, content, is_folder, new_order, datetime.now(), datetime.now(), current_user, current_user))
                new_id = cur.lastrowid
                self._add_history_entry(cur, new_id)
                return new_id
        except sqlite3.Error as e: print(f"Item add error: {e}"); return None
    def add_item_from_data(self, note_data: dict) -> int | None:
        if not self.conn: return None
        try:
            # created_by, last_edited_by が note_data に含まれていなければ 'unknown' を設定
            created_by = note_data.get('created_by', 'unknown')
            last_edited_by = note_data.get('last_edited_by', 'unknown')
            with self.db_transaction() as cur:
                cur.execute("SELECT MAX(display_order) FROM notes WHERE parent_id IS NULL AND is_deleted = 0")
                max_ord = cur.fetchone()[0]
                new_order = (max_ord if max_ord is not None else -1) + 1
                
                # ★★★ 修正箇所: created_by と last_edited_by を追加 ★★★
                cur.execute("""
                    INSERT INTO notes (parent_id, title, content, is_folder, display_order, created_at, updated_at, created_by, last_edited_by)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
                """, (None, note_data['title'], note_data['content'], False, new_order, note_data['created_at'], note_data['updated_at'], created_by, last_edited_by))
                
                return cur.lastrowid
        except sqlite3.Error as e:
            print(f"Item add from data error in {self.book_name}: {e}")
            return None
    def delete_item(self, item_id):
        if not self.conn or not item_id: return False
        try:
            with self.db_transaction() as cur:
                cur.execute("UPDATE notes SET is_deleted = 1, deleted_at = ? WHERE id = ?", (datetime.now(), item_id))
                
                limit = TRASH_LIMIT
                if limit > 0:
                    cur.execute("""
                        DELETE FROM notes
                        WHERE id IN (
                            SELECT id FROM notes
                            WHERE is_deleted = 1
                            ORDER BY deleted_at DESC
                            LIMIT -1 OFFSET ?
                        )
                    """, (limit,))
            return True
        except sqlite3.Error as e: print(f"Logical delete error: {e}"); return False
    def permanently_delete_item(self, note_id: int) -> bool:
        if not self.conn: return False
        try:
            with self.db_transaction() as cur:
                cur.execute("DELETE FROM notes WHERE id = ?", (note_id,))
            return True
        except sqlite3.Error as e:
            print(f"Permanent delete error for note ID {note_id} in {self.book_name}: {e}")
            return False
    def get_content(self, item_id):
        if not self.conn: return ""
        try:
            cur = self.conn.cursor()
            cur.execute("SELECT content FROM notes WHERE id = ?", (item_id,))
            row = cur.fetchone()
            return row[0] if row else ""
        except sqlite3.Error as e: print(f"Content fetch error: {e}"); return ""
    def get_full_note_data(self, note_id: int) -> dict | None:
        if not self.conn: return None
        try:
            cur = self.conn.cursor()
            # ★★★ 修正箇所: created_by, last_edited_by も取得 ★★★
            cur.execute("SELECT title, content, created_at, updated_at, created_by, last_edited_by FROM notes WHERE id = ?", (note_id,))
            row = cur.fetchone()
            if row:
                return {
                    "title": row[0], "content": row[1],
                    "created_at": row[2], "updated_at": row[3],
                    "created_by": row[4], "last_edited_by": row[5] # ★★★ 追加 ★★★
                }
            return None
        except sqlite3.Error as e:
            print(f"Full note data fetch error for ID {note_id} in {self.book_name}: {e}")
            return None
    def update_content(self, item_id, title, content):
        if not self.conn: return False
        try:
            # ★★★ 修正箇所: AppControllerからユーザー名を受け取る
            current_user = self.settings_manager.parent_controller.current_user
            
            with self.db_transaction() as cur:
                self._add_history_entry(cur, item_id)
                # ★★★ 修正箇所: last_edited_by を更新 ★★★
                cur.execute("UPDATE notes SET title = ?, content = ?, updated_at = ?, last_edited_by = ? WHERE id = ?", (title, content, datetime.now(), current_user, item_id))
            return True
        except sqlite3.Error as e: print(f"Content update error: {e}"); return False
    def get_next_new_memo_number(self) -> int:
        if not self.conn: return 1
        cur = self.conn.cursor()
        
        # --- ▼▼▼ ここが修正点です ▼▼▼ ---
        # ゴミ箱にあるノート(is_deleted=1)や、フォルダ(is_folder=1)を除外するように、
        # WHERE句に条件を追加します。
        cur.execute("SELECT title FROM notes WHERE title LIKE '新規メモ%' AND is_deleted = 0 AND is_folder = 0")
        # --- ▲▲▲ 修正ここまで ▲▲▲ ---
        rows = cur.fetchall()
        max_num = 0
        for row in rows:
            match = re.search(r'新規メモ([0-9]+)$', row[0])
            if match: max_num = max(max_num, int(match.group(1)))
        return max_num + 1
    def _add_history_entry(self, cursor, note_id):
        limit = HISTORY_LIMIT_PER_NOTE
        if limit <= 0: return
        
        # ★★★ 修正箇所: AppControllerからユーザー名を受け取る
        current_user = self.settings_manager.parent_controller.current_user
        
        cursor.execute("SELECT title, content FROM notes WHERE id = ?", (note_id,))
        row = cursor.fetchone()
        if row:
            title, content = row
            # ★★★ 修正箇所: edited_by を追加 ★★★
            cursor.execute("INSERT INTO notes_history (note_id, title, content, saved_at, edited_by) VALUES (?, ?, ?, ?, ?)",(note_id, title, content, datetime.now(), current_user))
            cursor.execute("DELETE FROM notes_history WHERE history_id IN (SELECT history_id FROM notes_history WHERE note_id = ? ORDER BY saved_at DESC LIMIT -1 OFFSET ?)", (note_id, limit))
    
    def get_note_history(self, note_id: int) -> list:
        if not self.conn: return []
        try:
            cur = self.conn.cursor()
            # ★★★ 修正箇所: SELECT文に edited_by を追加 ★★★
            cur.execute(
                "SELECT history_id, title, content, saved_at, edited_by FROM notes_history WHERE note_id = ? ORDER BY saved_at DESC",
                (note_id,)
            )
            return cur.fetchall()
        except sqlite3.Error as e:
            print(f"History fetch error for note ID {note_id} in {self.book_name}: {e}")
            return []
    def get_all_notes_with_content(self):
        if not self.conn: return []
        cur = self.conn.cursor()
        cur.execute("SELECT id, title, content, updated_at FROM notes WHERE is_folder = 0 AND is_deleted = 0")
        return cur.fetchall()
    def get_all_tags(self) -> list:
        if not self.conn: return []
        cur = self.conn.cursor()
        cur.execute("SELECT t.name, COUNT(nt.note_id) as count FROM tags t JOIN note_tags nt ON t.id = nt.tag_id GROUP BY t.name ORDER BY count DESC, t.name ASC")
        return [('#' + name, count) for name, count in cur.fetchall()]
    def update_links_for_note(self, source_note_id: int, targets: list[dict]):
        if not self.conn: return
        try:
            with self.db_transaction() as cur:
                cur.execute("DELETE FROM links WHERE source_note_id = ?", (source_note_id,))
                if targets:
                    # ★★★ 修正: target_book_id を保存する ★★★
                    data = [(source_note_id, t['id'], t['book_id']) for t in targets]
                    cur.executemany("INSERT OR IGNORE INTO links (source_note_id, target_note_id, target_book_id) VALUES (?, ?, ?)", data)
        except sqlite3.Error as e: print(f"Link update error for note {source_note_id} in {self.book_name}: {e}")
    def update_tags_for_note(self, note_id: int, tags: list[str]):
        if not self.conn: return
        tag_names = sorted(list(set([t.lstrip('#') for t in tags])))
        try:
            with self.db_transaction() as cur:
                cur.execute("DELETE FROM note_tags WHERE note_id = ?", (note_id,))
                if not tag_names: return
                cur.executemany("INSERT OR IGNORE INTO tags (name) VALUES (?)", [(name,) for name in tag_names])
                placeholders = ','.join('?' for _ in tag_names)
                cur.execute(f"SELECT id, name FROM tags WHERE name IN ({placeholders})", tag_names)
                tag_id_map = {name: id for id, name in cur.fetchall()}
                note_tags_data = [(note_id, tag_id_map[name]) for name in tag_names if name in tag_id_map]
                if note_tags_data: cur.executemany("INSERT INTO note_tags (note_id, tag_id) VALUES (?, ?)", note_tags_data)
        except sqlite3.Error as e: print(f"Tag update error: {e}")
    def get_backlinks_for_note(self, note_id: int, book_id: str) -> list[dict]:
        if not self.conn: return []
        try:
            cur = self.conn.cursor()
            # ★★★ 修正: target_book_id で検索する ★★★
            cur.execute("SELECT l.source_note_id, n.title FROM links l JOIN notes n ON l.source_note_id = n.id WHERE l.target_note_id = ? AND l.target_book_id = ? AND n.is_deleted = 0 ORDER BY n.title ASC", (note_id, book_id))
            return [{"id": r[0], "title": r[1]} for r in cur.fetchall()]
        except sqlite3.Error as e: print(f"Backlink fetch error: {e}"); return []
    def get_forward_links_for_note(self, note_id: int) -> list[dict]:
        if not self.conn: return []
        try:
            cur = self.conn.cursor()
            # ★★★ 修正: target_book_id を返す ★★★
            cur.execute("SELECT target_note_id, target_book_id FROM links WHERE source_note_id = ?", (note_id,))
            return [{"id": r[0], "book_id": r[1]} for r in cur.fetchall()]
        except sqlite3.Error as e: print(f"Forward link fetch error: {e}"); return []
    
    def get_all_note_ids(self) -> set[int]:
        if not self.conn: return set()
        cur = self.conn.cursor()
        cur.execute("SELECT id FROM notes WHERE is_folder = 0 AND is_deleted = 0")
        return {row[0] for row in cur.fetchall()}
    def _clean_content_for_tags(self, text: str) -> str:
        if not text: return ""
        clean = re.sub(r'^(\`\`\`|~~~)[^\n]*\n(?:.|\n)*?^\1\s*$\n?', '', text, flags=re.MULTILINE)
        clean = re.sub(r'`[^`]*`', '', clean)
        clean = re.sub(r'!?\[(.*?)\]\(.*?\)', r'\1', clean)
        return clean
        
    def _clean_content_for_link_extraction(self, text: str) -> str:
        if not text: return ""
        clean = re.sub(r'^(\`\`\`|~~~)[^\n]*\n(?:.|\n)*?^\1\s*$\n?', '', text, flags=re.MULTILINE)
        clean = re.sub(r'`[^`]*`', '', clean)
        return clean
    def rebuild_links_table(self, all_books_note_ids, book_name_to_id_map, book_id_to_name_map) -> tuple[bool, int, int]:
        if not self.conn: return False, 0, 0
        try:
            with self.db_transaction() as cur:
                cur.execute("DELETE FROM links")
                cur.execute("SELECT id, content FROM notes WHERE is_folder = 0 AND is_deleted = 0")
                all_notes_data = cur.fetchall()
                
                all_links_to_insert = []
                updated_notes_count = 0
                my_book_id = book_name_to_id_map.get(self.book_name)
                
                for note_id, content in all_notes_data:
                    if not content: continue
                    original_content = content
                    
                    def link_evaluator(match):
                        text, book_str, note_id_str = match.group('text'), match.group('book'), match.group('id')
                        if not book_str: return match.group(0)
                        if book_str in book_id_to_name_map: return match.group(0)
                        if book_str in book_name_to_id_map:
                            return f"[{text}]({book_name_to_id_map[book_str]}:{note_id_str})"
                        return ""
                    link_pattern = r'\[(?P<text>.*?)\]\((?:(?P<book>[\w\d_ -]+):)?(?P<id>\d+)\)'
                    updated_content = re.sub(link_pattern, link_evaluator, content)
                    
                    if updated_content != original_content:
                        cur.execute("UPDATE notes SET content = ? WHERE id = ?", (updated_content, note_id))
                        updated_notes_count += 1
                    
                    cleaned_content = self._clean_content_for_link_extraction(updated_content)
                    final_matches = re.finditer(r'\[.*?\]\((?:(?P<book>[\w\d_-]+):)?(?P<id>\d+)\)', cleaned_content)
                    for m in final_matches:
                        target_id, target_book_id = int(m.group("id")), m.group("book") or my_book_id
                        target_book_name = book_id_to_name_map.get(target_book_id)
                        
                        if target_book_name and target_book_name in all_books_note_ids and target_id in all_books_note_ids[target_book_name]:
                            # ★★★ 修正: linksテーブルには target_book_id を保存 ★★★
                            all_links_to_insert.append((note_id, target_id, target_book_id))
                if all_links_to_insert:
                    cur.executemany("INSERT OR IGNORE INTO links (source_note_id, target_note_id, target_book_id) VALUES (?, ?, ?)", all_links_to_insert)
            
            return True, len(all_links_to_insert), updated_notes_count
        except (sqlite3.Error, ValueError) as e:
            print(f"Link rebuild error in {self.book_name}: {e}")
            return False, 0, 0
# NoteTreeModel の rebuild_tags_table (置き換え)
    def rebuild_tags_table(self) -> tuple[bool, int]:
        if not self.conn: return False, 0
        try:
            with self.db_transaction() as cur:
                cur.execute("DELETE FROM note_tags"); cur.execute("DELETE FROM tags")
                cur.execute("SELECT id, content FROM notes WHERE is_folder = 0 AND is_deleted = 0")
                all_notes_data = cur.fetchall()
                all_unique_tags = set(); note_to_tags_map = collections.defaultdict(set)
                for note_id, content in all_notes_data:
                    if not content: continue
                    cleaned_content = self._clean_content_for_tags(content)
                    
                    # 「スペース/タブの直後」に「#が1つだけ(直後に#が続かない)」あるタグの名前部分のみを抽出
                    tags_in_note = re.findall(r'(?<=[ \t])#(?!#)([\w\d_/-]+)', cleaned_content)
            
                    # findallはキャプチャグループの中身(名前)だけを返すので、lstripは不要
                    for tag in tags_in_note: all_unique_tags.add(tag); note_to_tags_map[note_id].add(tag)
                if all_unique_tags:
                    cur.executemany("INSERT INTO tags (name) VALUES (?)", [(tag,) for tag in sorted(list(all_unique_tags))])
                    cur.execute("SELECT id, name FROM tags"); tag_id_map = {name: id for id, name in cur.fetchall()}
                    note_tags_data = []
                    for note_id, tags in note_to_tags_map.items():
                        for tag in tags:
                            if tag in tag_id_map: note_tags_data.append((note_id, tag_id_map[tag]))
                    if note_tags_data: cur.executemany("INSERT INTO note_tags (note_id, tag_id) VALUES (?, ?)", note_tags_data)
            return True, len(all_unique_tags)
        except (sqlite3.Error, ValueError) as e: print(f"Tag rebuild error in {self.book_name}: {e}"); return False, 0
    def get_deleted_notes(self) -> list:
        if not self.conn: return []
        cur = self.conn.cursor()
        cur.execute("SELECT id, title, deleted_at FROM notes WHERE is_deleted = 1 ORDER BY deleted_at DESC")
        return cur.fetchall()
    def restore_note(self, note_id: int) -> bool:
        if not self.conn: return False
        try:
            with self.db_transaction() as cur:
                cur.execute("UPDATE notes SET is_deleted = 0, deleted_at = NULL WHERE id = ?", (note_id,))
            return True
        except sqlite3.Error as e:
            print(f"Error restoring note {note_id} in {self.book_name}: {e}")
            return False
    def index(self, row, column, parent=QModelIndex()): return QModelIndex()
    def parent(self, index): return QModelIndex()
    def rowCount(self, parent=QModelIndex()): return 0
    def columnCount(self, parent=QModelIndex()): return 0
    def data(self, index, role=Qt.ItemDataRole.DisplayRole): return None
# NoteTreeModelクラス内に、他の _migrate... メソッドの隣に追加
    def _migrate_db_for_book_id_links(self, cursor, book_name_to_id_map):
        try:
            cursor.execute("PRAGMA table_info(links)")
            columns = [info[1] for info in cursor.fetchall()]
            # 新しい 'target_book_id' カラムが存在しなければ、マイグレーションを実行
            if 'target_book_id' not in columns and 'target_book_name' in columns:
                print(f"Migrating links table for book ID in '{self.book_name}'...")
                
                # 1. 新しいテーブルを作成
                cursor.execute("""
                    CREATE TABLE links_new (
                        source_note_id INTEGER NOT NULL,
                        target_note_id INTEGER NOT NULL,
                        target_book_id TEXT,
                        FOREIGN KEY (source_note_id) REFERENCES notes(id) ON DELETE CASCADE,
                        PRIMARY KEY (source_note_id, target_note_id, target_book_id)
                    )
                """)
                
                # 2. 古いテーブルからデータを読み込み、ブック名をブックIDに変換して新しいテーブルに挿入
                cursor.execute("SELECT source_note_id, target_note_id, target_book_name FROM links")
                old_links = cursor.fetchall()
                new_links_data = []
                for source_id, target_id, book_name in old_links:
                    book_id = book_name_to_id_map.get(book_name)
                    if book_id: # 対応するブックIDが見つかった場合のみ
                        new_links_data.append((source_id, target_id, book_id))
                
                if new_links_data:
                    cursor.executemany("INSERT INTO links_new (source_note_id, target_note_id, target_book_id) VALUES (?, ?, ?)", new_links_data)
                
                # 3. 古いテーブルを削除し、新しいテーブルをリネーム
                cursor.execute("DROP TABLE links")
                cursor.execute("ALTER TABLE links_new RENAME TO links")
                print("Links table migration completed.")
        except sqlite3.Error as e:
            print(f"Book ID link migration error: {e}")
# =================================================================
# 5. SERVICE CLASSES
# =================================================================
class DatabaseManager:
    def __init__(self, settings_manager):
        self.settings_manager = settings_manager
        self.connections = {}
        self.book_id_to_name_map = {}
        self.book_name_to_id_map = {}
        self.all_book_configs = {}
        self.load_connections()
    def load_connections(self):
        self.close_all_connections()
        db_configs = self.settings_manager.get('db_connections', {})
        load_on_startup = self.settings_manager.get('load_on_startup', {})
        self.all_book_configs = db_configs.copy()
        # 先にブックIDとブック名の対応辞書を、すべてのブックを対象に作成する
        temp_book_id_map = {}
        temp_name_map = {}
        for book_name, db_path in db_configs.items():
            book_id = os.path.splitext(os.path.basename(db_path))[0]
            temp_book_id_map[book_id] = book_name
            temp_name_map[book_name] = book_id
        
        self.book_id_to_name_map = temp_book_id_map
        self.book_name_to_id_map = temp_name_map
        # ▼▼▼ START OF MODIFICATION (BUG FIX) ▼▼▼
        # --- ここからが修正されたロジック ---
        # 起動時読み込みがオンになっているブックだけをループ処理する
        for book_name in db_configs.keys():
            # 設定でオンになっているか、あるいは設定自体が存在しない場合(デフォルトTrue)のみ読み込む
            if load_on_startup.get(book_name, True):
                self.connect_to_book(book_name)
        # --- 修正ロジックここまで ---
        # ▲▲▲ END OF MODIFICATION (BUG FIX) ▲▲▲
    def connect_to_book(self, book_name: str) -> bool:
        """指定されたブック名のデータベース接続を確立する"""
        if book_name in self.connections:
            return True
        db_path = self.all_book_configs.get(book_name)
        if not db_path:
            print(f"Error: No configuration found for book '{book_name}'")
            return False
        file_existed_before = os.path.exists(db_path)
        
        # ▼▼▼ 修正点: ここからが新しい検証ロジック ▼▼▼
        try:
            # NoteTreeModelは接続を試みるので、まずインスタンスを作成
            model = NoteTreeModel(db_path, book_name, self.settings_manager, self.book_name_to_id_map)
            
            if model.conn:
                # 接続成功後、本当にSQLite DBか検証
                cursor = model.conn.cursor()
                cursor.execute("PRAGMA integrity_check")
                result = cursor.fetchone()
                if result[0] != 'ok':
                    raise sqlite3.DatabaseError("Integrity check failed.")
            else:
                # model.connがNoneなら、そもそも接続に失敗している
                return False
        except sqlite3.DatabaseError:
            QMessageBox.warning(None, "データベースエラー", 
                f"ファイル「{os.path.basename(db_path)}」は有効なデータベースファイルではありません。")
            if 'model' in locals() and model.conn:
                model.conn.close() # 開いてしまった接続を閉じる
            return False
        # ▲▲▲ 修正ここまで ▲▲▲
        if model.conn:
            self.connections[book_name] = model
            if not file_existed_before:
                QMessageBox.information(None, "データベース作成", 
                    f"新しいブック「{book_name}」のデータベースファイルを作成しました。\nパス: {db_path}")
            return True
        else:
            print(f"Failed to connect or create book '{book_name}' at {db_path}")
            return False
        
    def close_all_connections(self):
        for model in self.connections.values(): model.close_connection()
        self.connections.clear()
        
    def get_note_model(self, book_name: str) -> NoteTreeModel | None:
        return self.connections.get(book_name)
    def get_all_book_names(self) -> list[str]:
        return list(self.connections.keys())
    def get_all_notes_for_search(self):
        all_notes = []
        # ▼▼▼ 変更点:ループ対象を接続済みの connections に限定 ▼▼▼
        for book_name, model in self.connections.items():
            notes_in_book = model.get_all_notes_with_content()
            for note_id, title, content, updated_at in notes_in_book:
                all_notes.append({
                    "book": book_name, "id": note_id, "title": title, 
                    "content": content, "updated_at": updated_at
                })
        return all_notes
    def get_all_notes_with_link_counts(self):
        link_counts = collections.defaultdict(lambda: {'forward': 0, 'back': 0})
        # ▼▼▼ 変更点:ループ対象を接続済みの connections に限定 ▼▼▼
        for book_name, model in self.connections.items():
            if not model.conn: continue
        
            source_book_id = self.book_name_to_id_map.get(book_name)
            if not source_book_id: continue
            try:
                cur = model.conn.cursor()
                cur.execute("SELECT source_note_id, target_note_id, target_book_id FROM links")
                for source_id, target_id, target_book_id in cur.fetchall():
                    link_counts[(source_book_id, source_id)]['forward'] += 1
                
                    if target_book_id:
                        link_counts[(target_book_id, target_id)]['back'] += 1
            except sqlite3.Error as e:
                print(f"Error counting links from '{book_name}': {e}")
    
        notes_with_links = []
        for (book_id, note_id), counts in link_counts.items():
            total = counts['forward'] + counts['back']
        
            if total > 0:
                book_name = self.book_id_to_name_map.get(book_id)
                # ▼▼▼ 変更点:ブックが読み込まれているか再度確認 ▼▼▼
                if book_name and book_name in self.connections:
                    title = self.get_note_title(book_name, note_id)
                    notes_with_links.append({
                        "book": book_name,
                        "id": note_id,
                        "title": title,
                        "total_links": total
                    })
    
        notes_with_links.sort(key=lambda x: x['total_links'], reverse=True)
        return notes_with_links
    def get_all_tags_across_books(self, sort_by_name: bool = False):
        all_tags = collections.defaultdict(int)
        # ▼▼▼ 変更点:ループ対象を接続済みの connections に限定 ▼▼▼
        for model in self.connections.values():
            for tag_name, count in model.get_all_tags():
                all_tags[tag_name] += count
        
        if sort_by_name:
            return sorted(all_tags.items(), key=lambda item: item[0].lower())
        else:
            return sorted(all_tags.items(), key=lambda item: (-item[1], item[0]))
        
    def find_note_by_id(self, book_name, note_id):
        model = self.get_note_model(book_name)
        return model.find_item_by_db_id(note_id) if model else None
    def get_note_title(self, book_name, note_id):
        model = self.get_note_model(book_name)
        if not model: return f"不明なノート({note_id})"
        
        try:
            cur = model.conn.cursor()
            cur.execute("SELECT title FROM notes WHERE id = ?", (note_id,))
            row = cur.fetchone()
            return row[0] if row else f"不明なノート({note_id})"
        except sqlite3.Error:
            return f"不明なノート({note_id})"
    
    def get_all_note_ids_by_book(self):
        return {book_name: model.get_all_note_ids() for book_name, model in self.connections.items()}
    def rebuild_all_links(self):
        all_ids = self.get_all_note_ids_by_book()
        total_success = True
        total_count = 0
        # ★★★ 追加: 修復されたノート数をカウントする変数 ★★★
        total_updated_notes = 0
        for model in self.connections.values():
            # ★★★ 修正箇所: 修復に必要なブック名の対応辞書を渡す ★★★
            success, count, updated_count = model.rebuild_links_table(
                all_ids, self.book_name_to_id_map, self.book_id_to_name_map
            )
            if not success: total_success = False
            total_count += count
            total_updated_notes += updated_count
        # ★★★ 修正箇所: 修復ノート数も返す ★★★
        return total_success, total_count, total_updated_notes
    def rebuild_all_tags(self):
        total_success = True
        total_count = 0
        for model in self.connections.values():
            success, count = model.rebuild_tags_table()
            if not success: total_success = False
            total_count += count
        return total_success, total_count
    def _move_associated_data(self, source_model: NoteTreeModel, dest_model: NoteTreeModel, old_id: int, new_id: int):
        try:
            with source_model.db_transaction() as source_cur:
                source_cur.execute("SELECT t.name FROM tags t JOIN note_tags nt ON t.id = nt.tag_id WHERE nt.note_id = ?", (old_id,))
                tags = ['#' + row[0] for row in source_cur.fetchall()]
                if tags:
                    dest_model.update_tags_for_note(new_id, tags)
        except sqlite3.Error as e:
            print(f"Error moving tags for note {old_id} -> {new_id}: {e}")
        try:
            with source_model.db_transaction() as source_cur, dest_model.db_transaction() as dest_cur:
                # ★★★ 修正点: target_book_name を target_book_id に変更 ★★★
                source_cur.execute("SELECT target_note_id, target_book_id FROM links WHERE source_note_id = ?", (old_id,))
                links = source_cur.fetchall()
                if links:
                    # ★★★ 修正点: 変数名を target_book_id に合わせる ★★★
                    link_data = [(new_id, target_id, target_book_id) for target_id, target_book_id in links]
                    # ★★★ 修正点: target_book_name を target_book_id に変更 ★★★
                    dest_cur.executemany("INSERT OR IGNORE INTO links (source_note_id, target_note_id, target_book_id) VALUES (?, ?, ?)", link_data)
        except sqlite3.Error as e:
            print(f"Error moving forward links for note {old_id} -> {new_id}: {e}")
    def _remap_links_across_all_books(self, id_map: dict, old_book: str, new_book: str):
        # ★★★ 修正点: ブック名をブックIDに変換する処理を追加 ★★★
        old_book_id = self.book_name_to_id_map.get(old_book)
        new_book_id = self.book_name_to_id_map.get(new_book)
        if not old_book_id or not new_book_id:
            print(f"Book ID not found for {old_book} or {new_book}")
            return
        for book_name, model in self.connections.items():
            try:
                with model.db_transaction() as cur:
                    placeholders = ','.join('?' * len(id_map))
                    # ★★★ 修正点: target_book_name を target_book_id に変更 ★★★
                    cur.execute(f"SELECT DISTINCT source_note_id FROM links WHERE target_book_id = ? AND target_note_id IN ({placeholders})", (old_book_id, *id_map.keys()))
                    notes_to_update_content = {row[0] for row in cur.fetchall()}
                    for old_id, new_id in id_map.items():
                        # ★★★ 修正点: target_book_name を target_book_id に変更 ★★★
                        cur.execute("UPDATE links SET target_note_id = ?, target_book_id = ? WHERE target_note_id = ? AND target_book_id = ?", (new_id, new_book_id, old_id, old_book_id))
                    for note_id in notes_to_update_content:
                        cur.execute("SELECT content FROM notes WHERE id = ?", (note_id,))
                        content_row = cur.fetchone()
                        if not content_row or not content_row[0]: continue
                        content = content_row[0]
                        original_content = content
                        
                        for old_id, new_id in id_map.items():
                            pattern_cross_book = re.compile(r'(\[.*?\]\()(' + re.escape(old_book) + r'\s*:\s*' + str(old_id) + r')(\))')
                            content = pattern_cross_book.sub(r'\1' + new_book + r':' + str(new_id) + r'\3', content)
                            if book_name == old_book:
                                pattern_local = re.compile(r'(\[.*?\]\()' + str(old_id) + r'(\))')
                                content = pattern_local.sub(r'\1' + new_book + r':' + str(new_id) + r'\2', content)
                        
                        if content != original_content:
                            cur.execute("UPDATE notes SET content = ? WHERE id = ?", (content, note_id))
            except sqlite3.Error as e:
                print(f"Failed to remap links in book '{book_name}': {e}")
    def move_notes_to_book(self, source_book_name: str, dest_book_name: str, note_ids: list[int]) -> tuple[bool, int]:
        source_model = self.get_note_model(source_book_name)
        dest_model = self.get_note_model(dest_book_name)
        if not source_model or not dest_model or source_book_name == dest_book_name: return False, 0
        
        source_book_id = self.book_name_to_id_map.get(source_book_name)
        if not source_book_id:
            print(f"Error: Could not find book ID for '{source_book_name}'")
            return False, 0
        id_map = {}
        moved_count = 0
        failed_ids = []
        for old_id in note_ids:
            note_data = source_model.get_full_note_data(old_id)
            if not note_data:
                print(f"Could not fetch data for note {old_id} from {source_book_name}. Skipping.")
                failed_ids.append(old_id); continue
            
            content = note_data['content']
            pattern_local_link = re.compile(r'(\[.*?\]\()(\d+)(\))')
            note_data['content'] = pattern_local_link.sub(
                r'\1' + source_book_name + r':\2\3', content
            )
            new_id = dest_model.add_item_from_data(note_data)
            if not new_id:
                print(f"Could not insert note {old_id} into {dest_book_name}. Skipping.")
                failed_ids.append(old_id); continue
            
            id_map[old_id] = new_id
            self._move_associated_data(source_model, dest_model, old_id, new_id)
        successfully_moved_ids = [old_id for old_id in note_ids if old_id not in failed_ids]
        for old_id in successfully_moved_ids:
            if source_model.permanently_delete_item(old_id):
                moved_count += 1
            else:
                print(f"CRITICAL: Failed to delete original note {old_id} from {source_book_name}. Destination has a copy.")
                if old_id in id_map: del id_map[old_id]
        return True, moved_count
    def close_connection(self, book_name: str):
        """指定されたブックの接続を閉じる"""
        if book_name in self.connections:
            model = self.connections[book_name]
            model.close_connection()
            del self.connections[book_name]
            print(f"ブック '{book_name}' の接続を閉じました。")
    def get_note_count_ranking_across_books(self):
        """全ブックを横断して、ユーザーごとのノート作成数を集計し、ランキングを返す"""
        all_counts = collections.defaultdict(int)
        for model in self.connections.values():
            try:
                cur = model.conn.cursor()
                cur.execute("SELECT created_by, COUNT(id) FROM notes WHERE is_folder = 0 GROUP BY created_by")
                for user, count in cur.fetchall():
                    all_counts[user] += count
            except sqlite3.Error as e:
                print(f"Error getting note count from '{model.book_name}': {e}")
        # 降順でソートして返す
        return sorted(all_counts.items(), key=lambda item: -item[1])
    def get_edit_count_ranking_across_books(self):
        """全ブックを横断して、ユーザーごとの総編集回数を集計し、ランキングを返す"""
        all_counts = collections.defaultdict(int)
        for model in self.connections.values():
            try:
                cur = model.conn.cursor()
                # 最後に編集したユーザー(last_edited_by)ごとに、編集回数(edit_count)を合計する
                cur.execute("SELECT last_edited_by, SUM(edit_count) FROM notes WHERE is_folder = 0 AND last_edited_by IS NOT NULL AND last_edited_by != '' GROUP BY last_edited_by")
                for user, count in cur.fetchall():
                    all_counts[user] += count
            except sqlite3.Error as e:
                print(f"Error getting edit count from '{model.book_name}': {e}")
        # 降順でソートして返す
        return sorted(all_counts.items(), key=lambda item: -item[1])
    
class SettingsManager:
    def __init__(self):
        data_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
        if not os.path.exists(data_dir): os.makedirs(data_dir, exist_ok=True)
        self.settings_path = os.path.join(data_dir, "settings.json")
        default_db_path = os.path.join(data_dir, "my_notes.db")
        self.default_settings = {
            'gui_theme': 'Dark',
            'app_font_family': QApplication.font().family(), 'app_font_size': 12,
            'editor_font_family': 'Courier New', 'editor_font_size': 12,
            'preview_theme': 'Dark', 'preview_css_path': '', 
            'db_connections': collections.OrderedDict([('my_notes', default_db_path)]),
            'window_layout': {
                'main_window': {
                    'is_maximized': False,
                    'x': 100, 'y': 100,
                    'width': 1400, 'height': 800
                },
                'splitters': {
                    'main_splitter': [250, 1150],
                    'right_splitter': [575, 575]
                },
                'editor_visible': True,
                'preview_visible': True,
            },
            'show_address_bar': False,
            'home_notes_by_book': {},
            'favorites': [], 'search_history': [], 'search_history_limit': 20,
            'history_limit_per_note': 50, 'lock_timeout_seconds': 60,
            'last_view_mode': 'chrono', 'last_open_note_context': None,
            'show_trash_view': False,
            'pinned_notes': [],
            'sort_by_title': False,
            'expanded_books': [],
            'book_icons': {},
            'trash_limit': 100,
            'show_zero_link_notes': False,
            'lockout_duration_seconds': 30,
            'lockin_duration_seconds': 30,
            'show_optional_toolbar': False,
            'show_advanced_toolbar': False,
            'hide_book_prefix_in_views': False,
            'db_check_interval_seconds': 10,
            'lock_check_interval_seconds': 10,
            'image_paste_folder': '' 
        }
        self.settings = self.load()
        self.parent_controller = None
    def load(self):
        try:
            with open(self.settings_path, 'r', encoding='utf-8') as f:
                loaded = json.load(f)
                
                if 'db_path' in loaded and 'db_connections' not in loaded:
                    loaded['db_connections'] = collections.OrderedDict([('My Notes', loaded['db_path'])])
                    del loaded['db_path']
                elif 'db_connections' in loaded and not isinstance(loaded['db_connections'], collections.OrderedDict):
                    loaded['db_connections'] = collections.OrderedDict(loaded['db_connections'])
                for k, v in self.default_settings.items(): loaded.setdefault(k, v)
                return loaded
        except (FileNotFoundError, json.JSONDecodeError): return self.default_settings.copy()
            
    def save(self):
        try:
            with open(self.settings_path, 'w', encoding='utf-8') as f: json.dump(self.settings, f, indent=4)
        except Exception as e: print(f"Settings save failed: {e}")
            
    def get(self, key, default=None): return self.settings.get(key, default)
    def set(self, key, value): self.settings[key] = value
    def get_editor_font(self): return QFont(self.get('editor_font_family'), self.get('editor_font_size'))
        
    def apply_app_theme(self):
        app = QApplication.instance()
        font = QFont(self.get('app_font_family'), self.get('app_font_size'))
        app.setFont(font)
        theme_name = self.get('gui_theme', 'Dark')
        colors = THEME_COLORS.get(theme_name, THEME_COLORS['Dark'])
        stylesheet = f"""
            QWidget {{ background-color: {colors['base']}; color: {colors['text']}; border: none; font-family: "{font.family()}"; font-size: {font.pointSize()}pt; }}
            QMainWindow, QDialog {{ background-color: {colors['base']}; }}
            QTextEdit, QPlainTextEdit, QLineEdit, QSpinBox, QFontComboBox, QComboBox, QListWidget, QTableWidget {{ background-color: {colors['input_bg']}; border: 1px solid {colors['border']}; padding: 3px; border-radius: 3px; }}
            QTreeView, QListView {{ background-color: {colors['input_bg']}; border: 1px solid {colors['border']}; border-radius: 3px; }}
            QTreeView::item, QListView::item, QListWidget::item {{ padding: 1px 6px; color: {colors['item_text']}; }}
            QTreeView::item:selected, QListView::item:selected, QListWidget::item:selected {{ background-color: {colors['item_selected_bg']}; color: {colors['item_selected_text']}; }}
            QPushButton {{ background-color: {colors['button_bg']}; border: 1px solid {colors['border']}; padding: 4px 8px; border-radius: 3px; }}
            QToolButton {{ background-color: transparent; border: none; padding: 3px; }}
            QToolButton:hover, QPushButton:hover {{ background-color: {colors['button_hover']}; }}
            QToolButton:pressed, QPushButton:pressed {{ background-color: {colors['button_pressed']}; }}
            QToolButton:checked {{ background-color: {colors['button_checked']}; }}
            QHeaderView::section {{ background-color: {colors['button_bg']}; padding: 4px; border: 1px solid {colors['border']}; }}
            QStatusBar {{ background-color: {colors['statusbar_bg']}; }}
            QSplitter::handle {{ background-color: {colors['handle']}; }}
            QScrollBar:vertical {{ border: none; background: {colors['input_bg']}; width: 10px; }}
            QScrollBar::handle:vertical {{ background: {colors['scrollbar']}; min-height: 20px; border-radius: 5px; }}
            QScrollBar:horizontal {{ border: none; background: {colors['input_bg']}; height: 10px; }}
            QScrollBar::handle:horizontal {{ background: {colors['scrollbar']}; min-width: 20px; border-radius: 5px; }}
            
            #editorToggleButton, #previewToggleButton {{
                padding: 2px 5px;
                border: 2px solid {colors['item_selected_bg']}; /* 枠線を2pxのテーマカラーに */
                border-radius: 3px;
            }}
            #editorToggleButton:checked, #previewToggleButton:checked {{
                background-color: {colors['item_selected_bg']};
                color: {colors['item_selected_text']};
                border: 2px solid {colors['item_selected_bg']}; /* チェック時も枠線の太さを維持 */
                border-radius: 3px;
            }}
        """
        app.setStyleSheet(stylesheet)
        
class MarkdownService:
    def __init__(self, settings_manager):
        self.settings_manager = settings_manager
        
        # ▼▼▼ START OF MODIFICATION ▼▼▼
        self.extensions = [
            'markdown.extensions.fenced_code',
            'markdown.extensions.extra',
            'markdown.extensions.tables',
            'markdown.extensions.footnotes',
            # toc拡張機能を追加
            'markdown.extensions.toc',
            TrulySaneListExtension(
                nested_indent=4,
                truly_sane=True
            ),
            ImageLinkExtension(), UnderlineExtension(), StrikeExtension(),
            IdLinkExtension(), TagExtension(), ExternalLinkExtension(), IndentExtension(),
            AdmonitionExtension(),
            ColoredHighlightExtension()
        ]
        
        # toc拡張機能の詳細設定
        self.extension_configs = {
            'markdown.extensions.toc': {
                # 見出しの横にアンカーリンク用の¶記号を表示する
                'permalink': True,
                # 生成されるulタグにCSSクラスを付与
                'toc_class': 'toc-list-group'
            },
        }
# MarkdownService クラス内
    def to_html(self, md_text: str, highlight_terms: list[str] = None, include_toc: bool = True) -> str:
        # --- 脚注定義ブロックの前処理 (変更なし) ---
        footnote_pattern = re.compile(r'^\[\^(.+?)]:\s*(.*(?:\n(?: {4}|\t).*)*)', re.MULTILINE)
        footnotes = [match.group(0) for match in footnote_pattern.finditer(md_text)]
        processed_text = md_text
        if footnotes:
            processed_text = footnote_pattern.sub('', processed_text).rstrip()
            processed_text += '\n\n' + '\n\n'.join(footnotes)
        md = markdown.Markdown(extensions=self.extensions, extension_configs=self.extension_configs)
        html_body = md.convert(processed_text)
        
        # ▼▼▼ ここからが変更点です ▼▼▼
        # include_toc フラグがTrueの場合のみ、目次を生成・追加する
        if include_toc:
            toc_html = md.toc
            cleaned_toc_html = re.sub(r"<li><p>(.*?)</p></li>", r"<li>\1</li>", toc_html)
            
            toc_floating_box_html = ""
            if cleaned_toc_html:
                toc_floating_box_html = f"""
                    <div id="toc-container">
                        <div id="toc-header"> 目次</div>
                        <div id="toc-list">
                            {cleaned_toc_html}
                        </div>
                    </div>
                """
            # 本文HTMLと目次ボックスを結合
            html = html_body + toc_floating_box_html
        else:
            # include_toc が False の場合は、本文のHTMLのみを使用
            html = html_body
        
        # ▲▲▲ 変更ここまで ▲▲▲
        # --- ハイライト処理 (変更なし) ---
        if not highlight_terms: return html
        
        tags = []; text_only_html = re.sub(r'<[^>]+>', lambda m: tags.append(m.group(0)) or f"__TAG_PLACEHOLDER_{len(tags)-1}__", html)
        for term in highlight_terms: text_only_html = re.sub(re.escape(term), r'<mark class="search-highlight">\g<0></mark>', text_only_html, flags=re.IGNORECASE)
        for i, tag in reversed(list(enumerate(tags))): text_only_html = text_only_html.replace(f"__TAG_PLACEHOLDER_{i}__", tag)
        return text_only_html
    def get_preview_css(self) -> str:
        custom_css = ""
        css_path = self.settings_manager.get('preview_css_path', '')
        if css_path and os.path.exists(css_path):
            try:
                with open(css_path, 'r', encoding='utf-8') as f: custom_css = f.read()
            except Exception as e: print(f"Custom CSS read error: {e}")
        
        theme_name = self.settings_manager.get('preview_theme', 'Dark')
        colors = PREVIEW_THEME_COLORS.get(theme_name, PREVIEW_THEME_COLORS['Dark'])
        
        # ▼▼▼ F-string内の { と } をすべて正しくエスケープした最終確定版 ▼▼▼
        return f"""<style>
            body {{ font-family: sans-serif; padding: 20px; background-color: {colors['bg']}; color: {colors['text']}; line-height: 1.6; }}
            a {{ color: {colors['link']}; text-decoration: none; }} a:hover {{ text-decoration: underline; }}
            .search-highlight {{ background-color: rgba(255, 255, 0, 0.7); }} mark {{ background-color: {colors['highlight']}; color: black; }}
            img {{ max-width:100%; height:auto; }} h1, h2 {{ border-bottom: 1px solid {colors['border']}; padding-bottom: 0.3em; margin-top: 1.5em; }}
            pre {{ background-color: {colors['code_bg']}; padding: 1em; border-radius: 5px; overflow-x: auto; }}
            pre > code {{ background-color: transparent; padding: 0; border-radius: 0; font-family: monospace; }}
            code:not(pre > code) {{ background-color: rgba(128,128,128,0.2); padding: 2px 4px; border-radius:3px; font-family: monospace; }}
            blockquote {{ border-left: 4px solid {colors['quote_border']}; padding-left: 1em; margin: 1.5em 0; color: {colors['quote_text']}; }}
            table {{ border-collapse: collapse; width: 100%; margin: 1.5em 0; }} th, td {{ border: 1px solid {colors['border']}; padding: 8px; text-align: left; }}
            th {{ background-color: {colors['header_bg']}; }}
            .internal-link, .tag-link {{ background-color: {colors['link_bg']}; color: {colors['link']}; padding: 2px 5px; border-radius: 4px; cursor: pointer; }}
            .internal-link::before {{ content:"🔗 "; }} .tag-link::before {{ content:"🏷️ "; }}
            .internal-link.broken-link {{ background-color: rgba(165, 165, 165, 0.2); color: #666; text-decoration: line-through; }}
            .internal-link.broken-link::before {{ content: "❌ "; }}
            .external-link-web::before {{ content:"🌐 "; }} .external-link-local::before {{ content:"📁 "; }}
            table.diff {{ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; border-collapse:collapse; width: 100%; }}
            .diff_header {{ background-color:{colors['diff_header']}; }}
            td.diff_header {{ text-align:right; font-weight: bold; padding: 4px; }}
            .diff_next {{ background-color:{colors['diff_header']}; }}
            .diff_add {{ background-color:{colors['diff_add']}; }}
            .diff_chg {{ background-color:{colors['diff_chg']}; }}
            .diff_sub {{ background-color:{colors['diff_sub']}; }}
            .admonition {{ padding: 10px 5px 1px 15px; margin: 1em 0; border-left: 6px solid; border-radius: 4px; background-color: rgba(128, 128, 128, 0.28); }}
            .admonition-title {{ font-weight: bold; margin-bottom: 0.5em; display: flex; align-items: center; }}
            .admonition > .admonition-title + p {{ margin-top: 0; }}
            .admonition-note {{ border-color: #58a6ff; }} .admonition-note .admonition-title {{ color: #58a6ff; }}
            .admonition-tip {{ border-color: #3fb950; }} .admonition-tip .admonition-title {{ color: #3fb950; }}
            .admonition-important {{ border-color: #a371f7; }} .admonition-important .admonition-title {{ color: #a371f7; }}
            .admonition-warning {{ border-color: #f0883e; }} .admonition-warning .admonition-title {{ color: #f0883e; }}
            .admonition-caution {{ border-color: #da3633; }} .admonition-caution .admonition-title {{ color: #da3633; }}
            .highlight-red, .highlight-green, .highlight-blue {{ padding: 2px 4px; border-radius: 3px; color: #000; }}
            .highlight-red   {{ background-color: rgba(255, 71, 71, 0.6); }}
            .highlight-green {{ background-color: rgba(127, 255, 0, 0.6); }}
            .highlight-blue  {{ background-color: rgba(0, 191, 255, 0.6); }}
            hr + .footnote ol {{ margin-top: 0; padding-top: 0; }}
            .indent-1 {{ margin-left: 2em; margin-right: 2em; }}
            .indent-2 {{ margin-left: 4em; margin-right: 4em; }}
            .indent-3 {{ margin-left: 6em; margin-right: 6em; }}
            .indent-4 {{ margin-left: 8em; margin-right: 8em; }}
            .footnote > hr:first-child {{ }}
            .footnote {{ margin-top: 0em; padding-top: 0em; }}
            .footnote ol li {{ font-size: 0.9em; }}
            .spoiler {{ background-color: #888; color: #888; cursor: pointer; transition: all 0.2s; }}
            .spoiler:hover {{ background-color: #999; color: #999; }}
            .spoiler.revealed {{ background-color: transparent; color: inherit; }}
            #toc-container {{ position: fixed; top: 20px; right: 20px; width: 250px; max-height: 80vh; background-color: {colors['code_bg']}; border: 1px solid {colors['border']}; border-radius: 5px; z-index: 1000; box-shadow: 0 4px 8px rgba(0,0,0,0.2); transition: all 0.3s ease; overflow: hidden; }}
            #toc-header {{ padding: 10px; font-weight: bold; cursor: pointer; background-color: {colors['header_bg']}; user-select: none; }}
            #toc-header::before {{ content: '▶ '; font-size: 0.8em; transition: transform 0.3s ease; display: inline-block; }}
            #toc-list {{ display: none; padding: 0 10px 10px 10px; max-height: calc(80vh - 40px); overflow-y: auto; }}
            #toc-container.open #toc-list {{ display: block; }}
            #toc-container.open #toc-header::before {{ transform: rotate(90deg); }}
            .toc-list-group ul {{ padding-left: 0; }} /* ネストされたリストのインデントをなくします */
            .toc-list-group li {{ list-style-type: disc; list-style-position: inside; }} /* マーカーを「・」にし、内側に表示します */
            .toc-list-group a {{ display: inline-block; padding: 3px 0; }}
            .headerlink {{ display: none !important; }}
            #lightbox-overlay {{ position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.85); z-index: 9999; display: none; justify-content: center; align-items: center; cursor: pointer; }}
            #lightbox-overlay.visible {{ display: flex; }}
            #lightbox-image {{ max-width: 90%; max-height: 90%; object-fit: contain; box-shadow: 0 0 25px rgba(0,0,0,0.5); cursor: default; }}
            #lightbox-close {{ position: absolute; top: 20px; right: 30px; font-size: 3em; color: #fff; cursor: pointer; line-height: 0.5; }}
            {custom_css}
            </style>
            <script>
                document.addEventListener('DOMContentLoaded', (event) => {{
                    // --- スポイラー用スクリプト ---
                    document.querySelectorAll('.spoiler').forEach(spoiler => {{
                        spoiler.addEventListener('click', () => {{
                            spoiler.classList.toggle('revealed');
                        }});
                    }});
                    // --- 目次用スクリプト ---
                    const tocContainer = document.getElementById('toc-container');
                    const tocHeader = document.getElementById('toc-header');
                    if (tocContainer && tocHeader) {{
                        tocHeader.addEventListener('click', () => {{
                            tocContainer.classList.toggle('open');
                        }});
                    }}
                    // --- ライトボックス用スクリプト ---
                    const lightboxOverlay = document.createElement('div');
                    lightboxOverlay.id = 'lightbox-overlay';
                    const lightboxImage = document.createElement('img');
                    lightboxImage.id = 'lightbox-image';
                    const closeButton = document.createElement('span');
                    closeButton.id = 'lightbox-close';
                    closeButton.innerHTML = '&times;';
                    lightboxOverlay.appendChild(closeButton);
                    lightboxOverlay.appendChild(lightboxImage);
                    document.body.appendChild(lightboxOverlay);
                    const closeLightbox = () => {{
                        lightboxOverlay.classList.remove('visible');
                    }};
                    document.querySelectorAll('a.image-link').forEach(link => {{
                        link.addEventListener('click', (e) => {{
                            e.preventDefault();
                            const imageUrl = link.href;
                            lightboxImage.src = imageUrl;
                            lightboxOverlay.classList.add('visible');
                        }});
                    }});
                    
                    closeButton.addEventListener('click', closeLightbox);
                    
                    lightboxOverlay.addEventListener('click', (e) => {{
                        if (e.target === lightboxOverlay) {{
                            closeLightbox();
                        }}
                    }});
                    document.addEventListener('keydown', (e) => {{
                        if (e.key === 'Escape' && lightboxOverlay.classList.contains('visible')) {{
                            closeLightbox();
                        }}
                    }});
                }});
            </script>
            """
            
    def get_themed_empty_page(self) -> str:
        return f"<!DOCTYPE html><html><head><meta charset='utf-8'>{self.get_preview_css()}</head><body>&nbsp;</body></html>"
    
# =================================================================
# 6. VIEW COMPONENTS (PANELS)
# =================================================================
class NoteTreePanel(QWidget):
    noteSelected = pyqtSignal(str, int)
    addMemoRequested = pyqtSignal(str)
    noteActionRequested = pyqtSignal(str, str, int, QModelIndex)
    customContextMenuRequested = pyqtSignal(str, QMenu)
    setHomeNoteRequested = pyqtSignal(str, int)
    def __init__(self, db_manager, settings_manager, view_toolbar, perform_tag_search_button, parent=None):
        super().__init__(parent)
        self.db_manager = db_manager
        self.settings_manager = settings_manager
        self.view_toolbar = view_toolbar
        self.perform_tag_search_button = perform_tag_search_button
        self.current_view_mode = 'chrono'
        # ▼▼▼ 変更点: 設定から直接ソート順を読み込みます ▼▼▼
        self.sort_by_title = self.settings_manager.get('sort_by_title', False)
        
        self.last_expanded_books = self.settings_manager.get('expanded_books', [])
        
        self.controller = None
        self.tag_search_action = None
        self.init_ui()
        self.is_in_search_view = False
        
# NoteTreePanel クラス内
    def init_ui(self):
        layout = QVBoxLayout(self); layout.setContentsMargins(5, 5, 5, 5)
        
        top_controls_layout = QHBoxLayout()
        self.search_input = SearchLineEdit(self)
        self.search_input.setPlaceholderText("🔍キーワード or ID (Enter)")
        self.search_input.setClearButtonEnabled(True)
        self.completer = QCompleter(self)
        self.completer.setModel(QStringListModel(self.settings_manager.get('search_history', []), self.completer))
        self.completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive); self.completer.setFilterMode(Qt.MatchFlag.MatchContains)
        self.completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
        self.search_input.setCompleter(self.completer)
        self.search_input.textChanged.connect(self.filter_items)
        self.search_input.returnPressed.connect(self._save_search_history)
        top_controls_layout.addWidget(self.search_input)
        # ▼▼▼ ここからが変更点です ▼▼▼
        # 1. 「名称順」チェックボックスを削除し、「Aa」チェックボックスを追加
        self.case_sensitive_checkbox = QCheckBox("Aa")
        self.case_sensitive_checkbox.setToolTip("大文字/小文字を区別して検索")
        # 2. チェック状態が変更されたら、即座に再検索を実行
        self.case_sensitive_checkbox.stateChanged.connect(self.filter_items)
        top_controls_layout.addWidget(self.case_sensitive_checkbox)
        # ▲▲▲ 変更ここまで ▲▲▲
        
        layout.addLayout(top_controls_layout)
        
        self.tree_view = QTreeView()
        self.tree_model = QStandardItemModel()
        self.tree_view.setModel(self.tree_model)
        self.tree_view.setHeaderHidden(True)
        
        self.tree_view.selectionModel().currentChanged.connect(self.on_item_selection_changed)
        
        self.tree_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.tree_view.customContextMenuRequested.connect(self.show_context_menu)
        layout.addWidget(self.tree_view)
        self.perform_tag_search_button.clicked.connect(self._perform_tag_search)
        
        layout.addWidget(self.view_toolbar)
        self.update_widget_fonts()
    def set_tag_search_action(self, action):
        """MainWindowから、ボタンを管理するアクションを受け取るためのメソッド"""
        self.tag_search_action = action
    def on_item_selection_changed(self, current_index: QModelIndex, previous_index: QModelIndex):
        item = self.tree_model.itemFromIndex(current_index)
        if not item: return
        
        item_data = item.data(Qt.ItemDataRole.UserRole)
        if not item_data: return
        
        item_type = item_data.get("type")
        if self.current_view_mode == 'tags' and item_type == "tag":
            return
        if item_type == "note":
            self.noteSelected.emit(item_data["book_name"], item_data["note_id"])
    def on_sort_order_changed(self, state):
        self.sort_by_title = (state == Qt.CheckState.Checked.value)
        self.settings_manager.set('sort_by_title', self.sort_by_title)
        
        self.last_expanded_books = self.get_expanded_books()
        self.update_view()
    def set_view_mode(self, view_id: str):
        # 現在が検索ビューではない場合にのみ、開閉状態を記憶するように条件を追加
        if self.current_view_mode in ['chrono', 'trash'] and not self.is_in_search_view:
            self.last_expanded_books = self.get_expanded_books()
        self.current_view_mode = view_id
        is_tag_view = (view_id == 'tags')
        
        # ▼▼▼ 変更点: ビュー切り替え時は、必ず検索状態をリセットします ▼▼▼
        self.is_in_search_view = False
        # ユーザー体験向上のため、検索ボックスもクリアします
        self.search_input.blockSignals(True)
        self.search_input.clear()
        self.search_input.blockSignals(False)
        original_tag_button_action = next((a for a in self.view_toolbar.actions() if a.data() == 'tags'), None)
        if original_tag_button_action:
                original_tag_button_action.setVisible(not is_tag_view)
        if self.tag_search_action:
                self.tag_search_action.setVisible(is_tag_view)
                
        self.tree_view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        if is_tag_view:
                 self.tree_view.clicked.connect(self.on_tag_item_clicked)
        else:
                 try:
                         self.tree_view.clicked.disconnect(self.on_tag_item_clicked)
                 except TypeError:
                         pass
        self.update_view()
        
    def on_tag_item_clicked(self, index: QModelIndex):
        if self.current_view_mode != 'tags': return
        item = self.tree_model.itemFromIndex(index)
        if item and item.isCheckable():
            current_state = item.checkState()
            new_state = Qt.CheckState.Unchecked if current_state == Qt.CheckState.Checked else Qt.CheckState.Checked
            item.setCheckState(new_state)
    def _perform_tag_search(self):
        """チェックされたタグを取得し、検索を実行してUIを元に戻す"""
        
        # 1. チェックされているタグを取得
        checked_tags = []
        for i in range(self.tree_model.rowCount()):
            item = self.tree_model.item(i)
            if item.checkState() == Qt.CheckState.Checked:
                tag_name = item.data(Qt.ItemDataRole.UserRole)["tag_name"]
                checked_tags.append(tag_name)
        if not checked_tags:
            QMessageBox.information(self, "情報", "検索するタグを1つ以上選択してください。")
            return
            
        # 2. 検索クエリ文字列を作成 (例: '"#tagA" "#tagB"')
        #    shlexが解釈できるよう、各タグをダブルクォートで囲む
        search_query = ' '.join([f'"{tag}"' for tag in checked_tags])
        
        # 3. 「All Notes (chrono)」ビューのアクションを探してプログラム的にクリックする
        #    これにより、set_view_mode('chrono')が呼ばれ、UIが自動的に元に戻る
        chrono_action = next((a for a in self.view_toolbar.actions() if a.data() == 'chrono'), None)
        if chrono_action:
            chrono_action.trigger() # trigger()でクリックをシミュレート
            
            # 4. 検索ボックスにクエリをセットする
            #    これによりtextChangedシグナルが発行され、自動的に検索が実行される
            self.search_input.setText(search_query)
    def on_tag_item_clicked(self, index: QModelIndex):
        if self.current_view_mode != 'tags': return
        item = self.tree_model.itemFromIndex(index)
        if item and item.isCheckable():
            current_state = item.checkState()
            new_state = Qt.CheckState.Unchecked if current_state == Qt.CheckState.Checked else Qt.CheckState.Checked
            item.setCheckState(new_state)
# NoteTreePanel クラスの update_view メソッドを、以下に完全に置き換えてください
    def update_view(self):
        #if not self.is_in_search_view:
        #    self.last_expanded_books = self.get_expanded_books()
    
        self.tree_model.clear()
        
        book_colors = self.settings_manager.get('book_colors', {})
        book_icons = self.settings_manager.get('book_icons', {})
        hide_prefix = self.settings_manager.get('hide_book_prefix_in_views', False)
        if self.current_view_mode == 'chrono':
            self.tree_view.setSortingEnabled(False) 
            
            pinned_notes_list = self.settings_manager.get('pinned_notes', [])
            pinned_set = {(note['book'], note['id']) for note in pinned_notes_list}
            home_notes_map = self.settings_manager.get('home_notes_by_book', {})
            all_book_names = self.db_manager.all_book_configs.keys()
            
            for book_name in all_book_names:
                model = self.db_manager.get_note_model(book_name) 
                icon_char = book_icons.get(book_name)
                if not icon_char:
                    icon_char = '🗒️'
                display_text_prefix = ""
                lock_state = self.controller.book_lock_states.get(book_name, 'locked_out')
                
                if book_name in self.controller.books_needing_update:
                    display_text_prefix = "🔄 "
                elif not model:
                    display_text_prefix = "ℹ️ "
                elif lock_state in ['pending_lock', 'pending_lockin']:
                    display_text_prefix = "⏳ "
                elif lock_state == 'writable':
                    display_text_prefix = ""
                else:
                    display_text_prefix = "🔒 "
                display_text = f"{display_text_prefix}{icon_char} {book_name}"
                book_item = QStandardItem(display_text)
                book_item.setIcon(QIcon())
                
                book_color_name = book_colors.get(book_name)
                item_color_brush = None
                if book_color_name:
                    color = QColor(book_color_name)
                    color.setAlpha(40)
                    item_color_brush = QBrush(color)
                    book_item.setBackground(item_color_brush)
                book_item.setData({"type": "book", "book_name": book_name}, Qt.ItemDataRole.UserRole)
                book_item.setEditable(False)
                if model: 
                    all_notes_in_book = [c for c in model.root_item.children if not c.is_folder]
                    
                    pinned_notes = [note for note in all_notes_in_book if (book_name, note.db_id) in pinned_set]
                    unpinned_notes = [note for note in all_notes_in_book if (book_name, note.db_id) not in pinned_set]
                    if self.sort_by_title:
                        pinned_notes.sort(key=lambda x: x.name.lower())
                        unpinned_notes.sort(key=lambda x: x.name.lower())
                    else:
                        pinned_notes.sort(key=lambda x: (getattr(x, 'is_new', False), x.updated_at if x.updated_at else datetime.min), reverse=True)
                        unpinned_notes.sort(key=lambda x: (getattr(x, 'is_new', False), x.updated_at if x.updated_at else datetime.min), reverse=True)
                    for note in pinned_notes + unpinned_notes:
                        note_item = QStandardItem()
                        
                        if item_color_brush:
                            note_item.setBackground(item_color_brush)
                        
                        display_text = note.name
                        if getattr(note, 'is_new', False):
                            display_text = f"* {note.name}"
                            note_item.setToolTip("このメモはまだ保存されていません")
                        is_home = home_notes_map.get(book_name) == note.db_id
                        is_pinned = (book_name, note.db_id) in pinned_set
                        icon = QIcon()
                        if is_home:
                            display_text = f"💠 {display_text}"
                        elif is_pinned:
                            icon = QIcon.fromTheme("emblem-favorite", self.style().standardIcon(QStyle.StandardPixmap.SP_DialogYesButton))
                        else:
                            icon = self.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon)
                        
                        note_item.setText(display_text)
                        note_item.setIcon(icon)
                        note_item.setData({"type": "note", "book_name": book_name, "note_id": note.db_id}, Qt.ItemDataRole.UserRole)
                        note_item.setEditable(False)
                        book_item.appendRow(note_item)
                
                self.tree_model.appendRow(book_item)
        elif self.current_view_mode == 'tags':
            self.tree_view.setSortingEnabled(False)
            tags_data = self.db_manager.get_all_tags_across_books(sort_by_name=self.sort_by_title)
            for tag_name, count in tags_data:
                tag_item = QStandardItem(f"{tag_name} ({count})")
                tag_item.setData({"type": "tag", "tag_name": tag_name}, Qt.ItemDataRole.UserRole)
                tag_item.setCheckable(True)
                tag_item.setCheckState(Qt.CheckState.Unchecked)
                tag_item.setEditable(False)
                self.tree_model.appendRow(tag_item)
        
        elif self.current_view_mode == 'links':
            self.tree_view.setSortingEnabled(False)
            sorted_notes = self.db_manager.get_all_notes_with_link_counts()
            show_zero_link = self.settings_manager.get('show_zero_link_notes', False)
            if show_zero_link:
                all_notes_map = {(note['book'], note['id']): note for note in self.db_manager.get_all_notes_for_search()}
                linked_notes_set = {(note['book'], note['id']) for note in sorted_notes}
                zero_link_notes = []
                for (book, note_id), note_data in all_notes_map.items():
                    if (book, note_id) not in linked_notes_set:
                        zero_link_notes.append({ "book": book, "id": note_id, "title": note_data['title'], "total_links": 0 })
                zero_link_notes.sort(key=lambda x: x['title'])
                sorted_notes.extend(zero_link_notes)
            for note_data in sorted_notes:
                if hide_prefix:
                    item_text = f"{note_data['title']} ({note_data['total_links']})"
                else:
                    item_text = f"[{note_data['book']}] {note_data['title']} ({note_data['total_links']})"
                note_item = QStandardItem(item_text)
                book_name = note_data['book']
                book_color_name = book_colors.get(book_name)
                if book_color_name:
                    color = QColor(book_color_name)
                    color.setAlpha(40)
                    note_item.setBackground(QBrush(color))
                note_item.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon))
                note_item.setData({ "type": "note", "book_name": note_data['book'], "note_id": note_data['id'] }, Qt.ItemDataRole.UserRole)
                note_item.setEditable(False)
                self.tree_model.appendRow(note_item)
        elif self.current_view_mode == 'trash':
            self.tree_view.setSortingEnabled(False)
            all_book_names = self.db_manager.all_book_configs.keys()
            for book_name in all_book_names:
                model = self.db_manager.get_note_model(book_name)
                if not model: 
                    continue
                deleted_notes = model.get_deleted_notes()
                if not deleted_notes: continue
                icon_char = book_icons.get(book_name, '🗒️')
                display_text = f"{icon_char} {book_name}"
                lock_state = self.controller.book_lock_states.get(book_name, 'locked_out')
                if lock_state == 'pending_lock':
                    display_text = f"⏳ {display_text}"
                elif lock_state != 'writable':
                    display_text = f"🔒 {display_text}"
                book_item = QStandardItem(display_text)
                book_color_name = book_colors.get(book_name)
                item_color_brush = None
                if book_color_name:
                    color = QColor(book_color_name)
                    color.setAlpha(40)
                    item_color_brush = QBrush(color)
                    book_item.setBackground(item_color_brush)
                book_item.setIcon(QIcon())
                book_item.setData({"type": "book", "book_name": book_name}, Qt.ItemDataRole.UserRole)
                book_item.setEditable(False)
                for note_id, title, deleted_at in deleted_notes:
                    note_item = QStandardItem(f"{title}")
                    if item_color_brush:
                        note_item.setBackground(item_color_brush)
                    if deleted_at:
                        if isinstance(deleted_at, str): deleted_at = datetime.fromisoformat(deleted_at)
                        note_item.setToolTip(f"削除日時: {deleted_at.strftime('%Y-%m-%d %H:%M')}")
                    note_item.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon))
                    note_item.setData({ "type": "deleted_note", "book_name": book_name, "note_id": note_id, "title": title }, Qt.ItemDataRole.UserRole)
                    note_item.setEditable(False)
                    book_item.appendRow(note_item)
                self.tree_model.appendRow(book_item)
        # ▼▼▼ 変更点3: 最後に、記憶しておいた self.last_expanded_books を使って状態を復元します ▼▼▼
        self.restore_expanded_state(self.last_expanded_books)
    def get_expanded_books(self) -> list[str]:
        expanded = []
        for i in range(self.tree_model.rowCount()):
            item = self.tree_model.item(i)
            if item and self.tree_view.isExpanded(item.index()):
                item_data = item.data(Qt.ItemDataRole.UserRole)
                if item_data and item_data.get("type") == "book":
                    expanded.append(item_data["book_name"])
        return expanded
    def restore_expanded_state(self, expanded_books: list[str] = None):
        if expanded_books is None:
            expanded_books = self.settings_manager.get('expanded_books', [])
        for i in range(self.tree_model.rowCount()):
            book_item = self.tree_model.item(i)
            item_data = book_item.data(Qt.ItemDataRole.UserRole)
            if item_data and item_data.get("type") == "book" and item_data.get("book_name") in expanded_books:
                self.tree_view.expand(book_item.index())
    def _perform_tag_search(self):
        """チェックされたタグを取得し、検索を実行してUIを元に戻す"""
        
        checked_tags = []
        for i in range(self.tree_model.rowCount()):
            item = self.tree_model.item(i)
            if item.checkState() == Qt.CheckState.Checked:
                tag_name = item.data(Qt.ItemDataRole.UserRole)["tag_name"]
                checked_tags.append(tag_name)
        if not checked_tags:
            QMessageBox.information(self, "情報", "検索するタグを1つ以上選択してください。")
            return
            
        search_query = ' '.join([f'"{tag}"' for tag in checked_tags])
        
        # 「All Notes (chrono)」ビューのアクションを探す
        chrono_action = next((a for a in self.view_toolbar.actions() if a.data() == 'chrono'), None)
        if chrono_action:
            # 1. 先にビューを「All Notes」に戻す
            #    これによりボタンの表示も自動的に元に戻る
            chrono_action.trigger()
            
            # 2. その後、検索ボックスにクエリをセットして検索を実行する
            #    (update_viewからclear処理を消したため、この順序で正しく動作する)
            self.search_input.setText(search_query)
    def set_move_mode(self, enabled: bool):
        root = self.tree_model.invisibleRootItem()
        for i in range(root.rowCount()):
            parent_item = root.child(i)
            if not parent_item: continue
            
            if parent_item.data(Qt.ItemDataRole.UserRole).get("type") == "book":
                 for j in range(parent_item.rowCount()):
                    note_item = parent_item.child(j)
                    if note_item and note_item.data(Qt.ItemDataRole.UserRole).get("type") == "note":
                        note_item.setCheckable(enabled)
                        if enabled: note_item.setCheckState(Qt.CheckState.Unchecked)
            elif parent_item.data(Qt.ItemDataRole.UserRole).get("type") == "note":
                parent_item.setCheckable(enabled)
                if enabled: parent_item.setCheckState(Qt.CheckState.Unchecked)
    def get_checked_notes(self) -> list[dict]:
        checked = []
        root = self.tree_model.invisibleRootItem()
        for i in range(root.rowCount()):
            parent_item = root.child(i)
            if not parent_item: continue
            if parent_item.data(Qt.ItemDataRole.UserRole).get("type") == "book":
                for j in range(parent_item.rowCount()):
                    note_item = parent_item.child(j)
                    if note_item and note_item.checkState() == Qt.CheckState.Checked:
                        item_data = note_item.data(Qt.ItemDataRole.UserRole)
                        checked.append({"book_name": item_data["book_name"], "note_id": item_data["note_id"]})
            elif parent_item.data(Qt.ItemDataRole.UserRole).get("type") == "note":
                 if parent_item.checkState() == Qt.CheckState.Checked:
                    item_data = parent_item.data(Qt.ItemDataRole.UserRole)
                    checked.append({"book_name": item_data["book_name"], "note_id": item_data["note_id"]})
        return checked
    def select_item_by_id(self, book_name: str, note_id: int):
        for i in range(self.tree_model.rowCount()):
            book_item = self.tree_model.item(i)
            if book_item.data(Qt.ItemDataRole.UserRole).get("book_name") == book_name:
                for j in range(book_item.rowCount()):
                    note_item = book_item.child(j)
                    if note_item and note_item.data(Qt.ItemDataRole.UserRole).get("note_id") == note_id:
                        idx = self.tree_model.indexFromItem(note_item)
                        self.tree_view.setCurrentIndex(idx)
                        self.tree_view.scrollTo(idx, QTreeView.ScrollHint.PositionAtCenter)
                        return
    def show_context_menu(self, pos):
        idx = self.tree_view.indexAt(pos)
        if not idx.isValid(): return
        item = self.tree_model.itemFromIndex(idx)
        item_data = item.data(Qt.ItemDataRole.UserRole)
        if not item_data: return
        menu = QMenu()
        item_type = item_data.get("type")
        if item_type == "book":
            book_name = item_data["book_name"]
            self.customContextMenuRequested.emit(book_name, menu)
            if menu.actions():
                menu.addSeparator()
            lock_state = self.controller.book_lock_states.get(book_name, 'locked_out')
            if self.current_view_mode != 'trash' and lock_state == 'writable':
                menu.addAction(QAction(f"このブックに新規メモ", self, triggered=lambda: self.addMemoRequested.emit(book_name)))
        elif item_type == "note":
            # ▼▼▼ ここからが変更点 ▼▼▼
            book_name = item_data["book_name"]
            note_id = item_data["note_id"]
            
            # AppControllerからブックのロック状態を取得します
            lock_state = self.controller.book_lock_states.get(book_name, 'locked_out')
            
            # ブックが書き込み可能な('writable')場合のみ、アクションを追加します
            if lock_state == 'writable':
                # 「ホームに設定」アクション
                set_home_action = QAction("💠 ホームに設定", self)
                # メッセージボックスの代わりに、シグナルを発行する
                set_home_action.triggered.connect(lambda: self.setHomeNoteRequested.emit(book_name, note_id))
                menu.addAction(set_home_action)
                menu.addSeparator()
                # 「削除」アクションを if ブロック内に移動
                delete_action = QAction("削除", self)
                delete_action.triggered.connect(lambda: self.noteActionRequested.emit("delete", book_name, note_id, idx))
                menu.addAction(delete_action)
            # ▲▲▲ 変更ここまで ▲▲▲
        elif item_type == "deleted_note":
            restore_action = QAction("復元", self)
            restore_action.triggered.connect(lambda: self.noteActionRequested.emit("restore", item_data["book_name"], item_data["note_id"], idx))
            menu.addAction(restore_action)
            perm_delete_action = QAction("完全に削除", self)
            perm_delete_action.triggered.connect(lambda: self.noteActionRequested.emit("permanent_delete", item_data["book_name"], item_data["note_id"], idx))
            menu.addAction(perm_delete_action)
        if menu.actions(): menu.exec(self.tree_view.viewport().mapToGlobal(pos))
    
# NoteTreePanel クラス内
    def filter_items(self, keyword=""):
        keyword = self.search_input.text().strip()
        is_search_active = bool(keyword)
        if is_search_active and not self.is_in_search_view:
            self.last_expanded_books = self.get_expanded_books()
            self.is_in_search_view = True
        elif not is_search_active and self.is_in_search_view:
            self.is_in_search_view = False
            self.update_view()
            return
        if self.is_in_search_view:
            self.tree_model.clear()
            self.tree_view.setSortingEnabled(False)
            book_colors = self.settings_manager.get('book_colors', {})
            hide_prefix = self.settings_manager.get('hide_book_prefix_in_views', False)
            is_case_sensitive = self.case_sensitive_checkbox.isChecked()
            
            # ▼▼▼ ここからが変更点です ▼▼▼
            # --- 1. book: 演算子の抽出 ---
            target_books = []
            # 'book:"Project A" Python' のようなケースに対応するため、正規表現で抽出
            book_pattern = re.compile(r'book:("[^"]+"|\S+)')
            
            # マッチした部分を記録し、元のキーワード文字列からは削除する
            remaining_keyword = book_pattern.sub(
                lambda m: target_books.append(m.group(1).strip('"')) or '', 
                keyword
            ).strip()
            try:
                lexer = shlex.shlex(remaining_keyword, posix=True)
                lexer.commenters = ''
                lexer.whitespace_split = True
                query_parts = list(lexer)
            except ValueError:
                query_parts = remaining_keyword.split()
            
            not_terms_base = [p[1:] for p in query_parts if p.startswith('-') and len(p) > 1]
            or_groups_base_str = " ".join([p for p in query_parts if not p.startswith('-')]).split('|')
            or_groups_base = [[term for term in group.strip().split()] for group in or_groups_base_str if group.strip()]
            # --- 2. 検索対象ノートの事前絞り込み ---
            all_notes = self.db_manager.get_all_notes_for_search()
            # book: が指定されていれば、ここでノートを絞り込む
            if target_books:
                notes_to_search = [note for note in all_notes if note["book"] in target_books]
            else:
                notes_to_search = all_notes
            
            # --- 3. 実際の検索ループ ---
            results = []
            for note in notes_to_search:
                title_to_search = note["title"]
                content_to_search = note["content"]
                note_id_str = str(note["id"])
                if not is_case_sensitive:
                    title_to_search = title_to_search.lower()
                    content_to_search = content_to_search.lower()
                
                searchable_text = title_to_search + "\n" + content_to_search
                not_terms = not_terms_base if is_case_sensitive else [t.lower() for t in not_terms_base]
                or_groups = or_groups_base if is_case_sensitive else [[t.lower() for t in g] for g in or_groups_base]
                if any(term in searchable_text for term in not_terms): continue
                
                is_or_match = not or_groups
                # book: のみでキーワードがない場合、絞り込んだ全ノートを結果とする
                if not or_groups and target_books and not remaining_keyword:
                    is_or_match = True
                if not is_or_match:
                    for group in or_groups:
                        match_in_group = True
                        for term in group:
                            search_term_for_text = ' ' + term if term.startswith('#') and not term.startswith('##') else term
                            is_match = (term == note_id_str) or (search_term_for_text in searchable_text)
                            if not is_match:
                                match_in_group = False
                                break
                        if match_in_group:
                            is_or_match = True
                            break
                if is_or_match: results.append(note)
            
            # ▲▲▲ 変更ここまで ▲▲▲
            for note in results:
                if hide_prefix:
                    item_text = note['title']
                else:
                    item_text = f"[{note['book']}] {note['title']}"
                note_item = QStandardItem(item_text)
                book_name = note['book']
                book_color_name = book_colors.get(book_name)
                if book_color_name:
                    color = QColor(book_color_name)
                    color.setAlpha(40)
                    note_item.setBackground(QBrush(color))
                note_item.setData({"type": "note", "book_name": note["book"], "note_id": note["id"]}, Qt.ItemDataRole.UserRole)
                note_item.setEditable(False)
                self.tree_model.appendRow(note_item)
    def update_widget_fonts(self):
        font = QApplication.font()
        self.search_input.setFont(font)
        self.tree_view.setFont(font)
    def keyPressEvent(self, event):
        key = event.key()
        modifiers = event.modifiers()
        current_index = self.tree_view.currentIndex()
        if key == Qt.Key.Key_Up:
            self.tree_view.moveCursor(QAbstractItemView.CursorAction.MoveUp, modifiers)
            event.accept()
            return
        elif key == Qt.Key.Key_Down:
            self.tree_view.moveCursor(QAbstractItemView.CursorAction.MoveDown, modifiers)
            event.accept()
            return
        elif key == Qt.Key.Key_Left or key == Qt.Key.Key_Right:
            if self.current_view_mode == 'tags' and current_index.isValid():
                item = self.tree_model.itemFromIndex(current_index)
                if item and item.isCheckable():
                    item.setCheckState(Qt.CheckState.Unchecked if item.checkState() == Qt.CheckState.Checked else Qt.CheckState.Checked)
                    event.accept()
                    return
            if key == Qt.Key.Key_Left:
                if current_index.isValid() and self.tree_view.isExpanded(current_index):
                    self.tree_view.collapse(current_index)
                elif current_index.isValid():
                    self.tree_view.setCurrentIndex(current_index.parent())
            else:
                if current_index.isValid() and self.tree_view.model().hasChildren(current_index) and not self.tree_view.isExpanded(current_index):
                    self.tree_view.expand(current_index)
                elif current_index.isValid() and self.tree_view.model().hasChildren(current_index):
                    self.tree_view.setCurrentIndex(current_index.child(0, 0))
            event.accept()
            return
        if key == Qt.Key.Key_Delete:
            if current_index.isValid():
                item = self.tree_model.itemFromIndex(current_index)
                item_data = item.data(Qt.ItemDataRole.UserRole)
                if item_data and (item_data.get("type") == "note" or item_data.get("type") == "deleted_note"):
                    action_type = "delete" if item_data.get("type") == "note" else "permanent_delete"
                    self.noteActionRequested.emit(action_type, item_data["book_name"], item_data["note_id"], current_index)
                    event.accept()
                    return
        if key == Qt.Key.Key_Escape:
            self.tree_view.clearSelection()
            event.accept()
            return
        super().keyPressEvent(event)
    def add_new_item_to_view(self, book_name: str, temp_id: int, title: str):
        new_note_item = TreeItem(name=title, db_id=temp_id, book_name=book_name)
        setattr(new_note_item, 'is_new', True)
        model = self.db_manager.get_note_model(book_name)
        if model:
            model.root_item.children.insert(0, new_note_item)
            self.update_view()
            QTimer.singleShot(50, lambda: self.select_item_by_id(book_name, temp_id))
    def _save_search_history(self):
        term = self.search_input.text().strip()
        if not term: return
        limit = self.settings_manager.get('search_history_limit', 20)
        if limit <= 0: return
        history = self.settings_manager.get('search_history', [])
        if term in history: history.remove(term)
        history.insert(0, term)
        while len(history) > limit: history.pop()
        self.settings_manager.set('search_history', history)
        self.completer.setModel(QStringListModel(history, self.completer))
    def select_item_at_index(self, index: QModelIndex):
        """指定されたインデックスのアイテムを選択状態にし、その内容を表示させる"""
        if index and index.isValid():
            self.tree_view.setCurrentIndex(index)
            self.tree_view.scrollTo(index, QTreeView.ScrollHint.PositionAtCenter)
            self.on_item_selection_changed(index, QModelIndex())
class EditorPanel(QWidget):
    saveRequested = pyqtSignal()
    contentModified = pyqtSignal()
    internalLinkRequested = pyqtSignal()
    addTagsRequested = pyqtSignal()
    tableRequested = pyqtSignal()
    pasteImageFromClipboardRequested = pyqtSignal() 
    def __init__(self, settings_manager, parent=None):
        super().__init__(parent); self._is_modified = False
        self.settings_manager = settings_manager
        self.init_ui(); self.editor.textChanged.connect(self.on_content_modified)
    def on_content_modified(self): self._is_modified = True; self.contentModified.emit()
    def is_modified(self): return self._is_modified
    def set_modified(self, state): self._is_modified = state
    
    def init_ui(self):
        layout = QVBoxLayout(self); layout.setContentsMargins(0, 0, 0, 0); layout.setSpacing(0)
        self.toolbar_basic = QToolBar("基本装飾")
        self.toolbar_optional = QToolBar("オプション装飾")
        self.toolbar_advanced = QToolBar("高度な装飾")
        
        self.setup_toolbars()
        
        layout.addWidget(self.toolbar_basic)
        layout.addWidget(self.toolbar_optional)
        layout.addWidget(self.toolbar_advanced)
        
        self.editor = PlainTextEdit(); self.editor.selectionChanged.connect(self.update_toolbar_state)
        layout.addWidget(self.editor); self.setEnabled(False)
        self.update_toolbar_visibility()
    def update_toolbar_visibility(self):
        self.toolbar_optional.setVisible(self.settings_manager.get('show_optional_toolbar', False))
        self.toolbar_advanced.setVisible(self.settings_manager.get('show_advanced_toolbar', False))
    
    def setup_toolbars(self):
        # --- 1. 基本装飾ツールバー ---
        h_btn = QToolButton(self); h_btn.setText("H"); h_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
        h_menu = QMenu(h_btn)
        for i in range(1, 4): h_menu.addAction(QAction(f"見出し {i}", self, triggered=lambda c, l=i: self.prefix_lines("#" * l + " ")))
        h_btn.setMenu(h_menu); self.toolbar_basic.addWidget(h_btn)
        
        self.toolbar_basic.addAction(QAction("B", self, toolTip="太字", triggered=lambda: self.wrap_selection("**", "**", "太字")))
        self.toolbar_basic.addAction(QAction("I", self, toolTip="斜体", triggered=lambda: self.wrap_selection("*", "*", "斜体")))
        self.toolbar_basic.addAction(QAction("U", self, toolTip="下線", triggered=lambda: self.wrap_selection("++", "++", "下線")))
        self.toolbar_basic.addAction(QAction("S", self, toolTip="取り消し線", triggered=lambda: self.wrap_selection("~~", "~~", "打消")))
        self.toolbar_basic.addSeparator()
        self.toolbar_basic.addAction(QAction("内部リンク", self, toolTip="内部リンク", triggered=self.internalLinkRequested))
        self.toolbar_basic.addAction(QAction("外部リンク", self, toolTip="外部リンク", triggered=self.insert_link))
        self.toolbar_basic.addAction(QAction("#タグ", self, toolTip="タグ", triggered=lambda: self.wrap_selection("#", "", "タグ")))
        self.toolbar_basic.addAction(QAction("🏷️+", self, toolTip="既存タグから選択", triggered=self.addTagsRequested.emit))
        self.toolbar_basic.addAction(QAction("↵", self, toolTip="改行 (半角スペース2つ)", triggered=lambda: self.editor.textCursor().insertText("  \n")))
        
        spacer = QWidget(); spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred); self.toolbar_basic.addWidget(spacer)
        save_act = QAction("💾 保存", self, triggered=self.saveRequested); self.toolbar_basic.addAction(save_act)
        # --- 2. オプション装飾ツールバー ---
        self.toolbar_optional.addAction(QAction("→", self, toolTip="インデント", triggered=self.indent_current_selection))
        self.toolbar_optional.addAction(QAction("←", self, toolTip="アンインデント", triggered=self.unindent_current_selection))
        self.toolbar_optional.addAction(QAction("引用", self, toolTip="引用", triggered=lambda: self.prefix_lines("> ")))
        self.toolbar_optional.addAction(QAction("タスク", self, toolTip="タスクリスト", triggered=lambda: self.prefix_lines("- [ ] ")))
        self.toolbar_optional.addSeparator()
        self.toolbar_optional.addAction(QAction("箇条書き", self, toolTip="箇条書き", triggered=lambda: self.prefix_lines("- ")))
        self.toolbar_optional.addAction(QAction("番号リスト", self, toolTip="番号リスト", triggered=lambda: self.prefix_lines("1. ", ordered=True)))
        self.toolbar_optional.addSeparator()
        self.toolbar_optional.addAction(QAction("テーブル", self, toolTip="テーブル", triggered=self.tableRequested.emit))
        self.toolbar_optional.addAction(QAction("—", self, toolTip="罫線", triggered=lambda: self.editor.textCursor().insertText("\n\n---\n\n")))
 
        # --- 3. 高度な装飾ツールバー ---
        self.toolbar_advanced.addAction(QAction("`...`", self, toolTip="インラインコード", triggered=lambda: self.wrap_selection("`", "`", "code")))
        self.toolbar_advanced.addAction(QAction("```", self, toolTip="コードブロック", triggered=lambda: self.wrap_selection("```\n", "\n```", "コード")))
        admonition_btn = QToolButton(self)
        admonition_btn.setText("[!]")
        admonition_btn.setToolTip("注意喚起ブロックを挿入")
        admonition_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
        admonition_menu = QMenu(admonition_btn)
        
        admonition_types = ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"]
        for ad_type in admonition_types:
            action = QAction(ad_type, self)
            action.triggered.connect(lambda checked, t=ad_type: self.editor.textCursor().insertText(f"\n> [!{t}]\n> "))
            admonition_menu.addAction(action)
        admonition_btn.setMenu(admonition_menu)
        self.toolbar_advanced.addWidget(admonition_btn)
        paste_img_action = QAction("画像貼付", self)
        paste_img_action.setToolTip("クリップボードから画像を貼り付け (要設定)")
        paste_img_action.triggered.connect(self.pasteImageFromClipboardRequested.emit)
        self.toolbar_advanced.addAction(paste_img_action)
        self.toolbar_advanced.addAction(QAction("画像", self, toolTip="画像挿入", triggered=self.insert_image))
        spoiler_action = QAction("Spoiler", self)
        spoiler_action.setToolTip("スポイラーブロックを挿入 (クリックで表示)")
        spoiler_action.triggered.connect(
            lambda: self.wrap_selection(
                '<span class="spoiler">', 
                '</span>', 
                '隠すテキスト'
            )
        )
        self.toolbar_advanced.addAction(spoiler_action)
        highlight_btn = QToolButton(self)
        highlight_btn.setText("Marker")
        highlight_btn.setToolTip("蛍光ペンハイライトを挿入")
        highlight_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
        highlight_menu = QMenu(highlight_btn)
        highlights = [("赤", "r"), ("緑", "g"), ("青", "b")]
        for name, char_code in highlights:
            action = QAction(name, self)
            action.triggered.connect(
                lambda checked, char=char_code: self.wrap_selection(
                    f"@@{char}:", "@@", "ハイライト"
                )
            )
            highlight_menu.addAction(action)
        highlight_btn.setMenu(highlight_menu)
        self.toolbar_advanced.addWidget(highlight_btn)
        footnote_btn = QToolButton(self)
        footnote_btn.setText("脚注")
        footnote_btn.setToolTip("脚注の参照または定義を挿入")
        footnote_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
        
        footnote_menu = QMenu(footnote_btn)
        
        ref_action = QAction("脚注参照 [^ID] を挿入", self)
        ref_action.triggered.connect(lambda: self.wrap_selection("[^", "]", "ID"))
        footnote_menu.addAction(ref_action)
        
        def_action = QAction("脚注定義 [^ID]: を挿入", self)
        # 脚注定義は通常、文書の末尾に書くため、改行を入れてから挿入する
        def_action.triggered.connect(lambda: self.editor.textCursor().insertText("\n[^ID]: "))
        footnote_menu.addAction(def_action)
        
        footnote_btn.setMenu(footnote_menu)
        self.toolbar_advanced.addWidget(footnote_btn)
        emoji_btn = QToolButton(self)
        emoji_btn.setText("記号")
        emoji_btn.setToolTip("絵文字を挿入")
        emoji_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
        emoji_menu = QMenu(emoji_btn)
        
        EMOJI_LIST = [
            ('🔴', '重要'), ('🟡', '警告'), ('🟢', '問題なし'), ('⚠️', '警告、注意点'),
            ('❗', '注意、注目'), ('✔', 'チェック、完了'), ('❌', 'NG、却下'), ('💡', 'アイデア、提案'),
            ('❓', '質問、未確認事項'), ('💬', 'コミュニケーション、議論'), ('👥', '連絡先、担当者'),
            ('✉️', 'メール'), ('📞', '電話'), ('📎', '添付ファイル'), ('🔗', '参考、リンク'),
            ('📖', 'ドキュメント、手順書'), ('📄', '書類'), ('📁', 'フォルダ'), ('📊', 'データ、グラフ'),
            ('💰', 'お金、コスト'), ('📦', '関連資材、荷物'), ('🗓️', '期間、締切'),
            ('⏰', '時間、リマインダー'), ('🏁', 'ゴール、最終目標'), ('🔎', '検索、調査'),
            ('🧰', 'メンテナンス、ツール'), ('🔒', 'セキュリティ、鍵'), ('🔰', '初心者向け、TIPS'),
            ('👉', '参照、こちら')
        ]
        
        for emoji, desc in EMOJI_LIST:
            action = QAction(f"{emoji} {desc}", self)
            action.triggered.connect(lambda checked, text=emoji: self.editor.textCursor().insertText(text))
            emoji_menu.addAction(action)
        emoji_btn.setMenu(emoji_menu)
        self.toolbar_advanced.addWidget(emoji_btn)
    def indent_current_selection(self):
        self.editor.indent_selection(self.editor.textCursor())
    def unindent_current_selection(self):
        self.editor.unindent_selection(self.editor.textCursor())
    def set_font(self, font): 
        self.editor.setFont(font)
        self.editor.setTabStopDistance(self.editor.fontMetrics().horizontalAdvance(' ') * 4)
        for tb in [self.toolbar_basic, self.toolbar_optional, self.toolbar_advanced]:
            for action in tb.actions():
                widget = tb.widgetForAction(action)
                if widget:
                    widget.setFont(QApplication.font())
    def insert_image(self):
        path, _ = QFileDialog.getOpenFileName(self, "画像ファイルを選択", "", "Image Files (*.png *.jpg *.jpeg *.gif *.bmp)")
        if path:
            url = QUrl.fromLocalFile(path).toString()
            self.editor.textCursor().insertText(f"![{os.path.basename(path)}]({url})")
    def set_content(self, text): self.editor.blockSignals(True); self.editor.setPlainText(text); self.set_modified(False); self.editor.blockSignals(False)
    def setEnabled(self, enabled):
        """パネル全体の有効/無効を切り替える (Qtの標準動作)"""
        # 親ウィジェットの有効/無効状態を設定
        super().setEnabled(enabled)
        
        # テキスト入力エリア自体の有効/無効を設定
        # ReadOnly状態とは別に管理する
        self.editor.setEnabled(enabled)
        # ツールバーの有効/無効を設定
        self.toolbar_basic.setEnabled(enabled)
        self.toolbar_optional.setEnabled(enabled)
        self.toolbar_advanced.setEnabled(enabled)
    def update_toolbar_state(self): pass
    def wrap_selection(self, prefix, suffix, placeholder=""): self.editor.textCursor().insertText(f"{prefix}{self.editor.textCursor().selectedText() or placeholder}{suffix}"); self.editor.setFocus()
    def prefix_lines(self, prefix, ordered=False):
        cursor = self.editor.textCursor()
        start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd()
        cursor.setPosition(start_pos)
        first_block_num = cursor.blockNumber()
        cursor.setPosition(end_pos)
        last_block_num = cursor.blockNumber()
        
        if cursor.atBlockStart() and end_pos != start_pos:
            last_block_num -= 1
        
        cursor.beginEditBlock()
        
        first_block = self.editor.document().findBlockByNumber(first_block_num)
        first_line_text = first_block.text()
        
        heading_pattern = re.compile(r'^(\s*)#+\s')
        ordered_list_pattern = re.compile(r'^(\s*)\d+\.\s')
        bullet_list_pattern = re.compile(r'^(\s*)-\s')
        quote_pattern = re.compile(r'^(\s*)>\s')
        task_unchecked_pattern = re.compile(r'^(\s*)-\s\[\s\]\s')
        task_checked_pattern = re.compile(r'^(\s*)-\s\[x\]\s', re.IGNORECASE)
        action_to_perform = "add"
        
        is_task_toggle = prefix.strip() == "- [ ]"
        if is_task_toggle:
            if task_unchecked_pattern.match(first_line_text):
                action_to_perform = "check"
            elif task_checked_pattern.match(first_line_text):
                action_to_perform = "remove"
        elif prefix.strip().startswith("#"):
            match = heading_pattern.match(first_line_text)
            if match and match.group(0).strip() == prefix.strip():
                action_to_perform = "remove"
            elif match:
                action_to_perform = "replace"
        elif ordered:
            if ordered_list_pattern.match(first_line_text):
                action_to_perform = "remove"
        else: # 箇条書き・引用
            pattern_to_check = bullet_list_pattern if prefix.strip() == "-" else quote_pattern
            if pattern_to_check.match(first_line_text):
                action_to_perform = "remove"
        for i, block_num in enumerate(range(first_block_num, last_block_num + 1)):
            block = self.editor.document().findBlockByNumber(block_num)
            temp_cursor = QTextCursor(block)
            
            temp_cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock)
            temp_cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor)
            original_text = temp_cursor.selectedText()
            
            indent_match = re.match(r'^(\s*)', original_text)
            indent = indent_match.group(1) if indent_match else ""
            
            def remove_decoration(text):
                patterns = [
                    task_checked_pattern, task_unchecked_pattern,
                    ordered_list_pattern, bullet_list_pattern,
                    quote_pattern, heading_pattern
                ]
                for p in patterns:
                    match = p.match(text.lstrip())
                    if match:
                        return text.lstrip()[len(match.group(0)):]
                return text.lstrip()
            clean_text = remove_decoration(original_text)
            if action_to_perform == "check":
                new_text = indent + "- [x] " + clean_text
            elif action_to_perform == "remove":
                new_text = indent + clean_text
            elif action_to_perform == "replace": # 見出し置換
                 new_text = indent + prefix + clean_text
            else: # "add"
                if ordered:
                    new_text = indent + f"{i + 1}. " + clean_text
                else:
                    new_text = indent + prefix + clean_text
            
            temp_cursor.insertText(new_text)
        cursor.endEditBlock()
        self.editor.setFocus()
        
    def insert_link(self):
        cur = self.editor.textCursor()
        dlg = LinkDialog(self)
        dlg.text_edit.setText(cur.selectedText())
        
        # --- ▼▼▼ ここからが追加・変更部分です ▼▼▼ ---
        clipboard = QApplication.clipboard()
        # strip()で前後の空白を除去
        clipboard_text = clipboard.text().strip()
        # クリップボードにテキストがあり、かつエディタで何も選択していない場合にのみ自動入力を試みる
        if clipboard_text and not cur.hasSelection():
            # 1. URL形式かどうかをチェック (http, https, ftp, mailto, file スキーム)
            is_url = clipboard_text.lower().startswith(('http://', 'https://', 'ftp://', 'mailto:', 'file:'))
            
            # 2. 実在するファイルパスまたはフォルダパスかどうかをチェック
            #    os.path.existsは両方に対応できる
            is_path = False
            try:
                # パスとして有効か、実際に存在するかをチェック
                # 長すぎる文字列や不正な文字でエラーになる可能性を考慮
                is_path = os.path.exists(clipboard_text)
            except (TypeError, ValueError):
                is_path = False # チェック中にエラーが出た場合はパスではないとみなす
            # URLまたは実在するパスであれば、URL欄に自動で設定
            if is_url or is_path:
                dlg.url_edit.setText(clipboard_text)
        # --- ▲▲▲ 追加・変更ここまで ▲▲▲ ---
        if dlg.exec():
            text, url_input = dlg.get_data()
            
            if not url_input:
                return
            processed_path = url_input.strip()
            if processed_path.startswith('"') and processed_path.endswith('"'):
                processed_path = processed_path[1:-1]
            final_text = text
            final_url = ""
            if processed_path.lower().startswith(('http://', 'https://', 'ftp://', 'mailto:')):
                final_url = processed_path
                if not final_text:
                    final_text = processed_path
            else:
                final_url = QUrl.fromLocalFile(processed_path).toString()
                
                if not final_text:
                    if os.path.isdir(processed_path):
                        clean_path = os.path.normpath(processed_path)
                        folder_name = os.path.basename(clean_path) or clean_path
                        final_text = f"📁 {folder_name}"
                    else:
                        final_text = os.path.basename(processed_path) or processed_path
            
            cur.insertText(f"[{final_text}](<{final_url}>)")
    def insert_internal_link(self, book_name, item_id, item_name):
        cur = self.editor.textCursor(); cur.insertText(f"[{cur.selectedText() or item_name}]({book_name}:{item_id})")
    def add_tags(self, tags_to_add: list[str]):
        if not tags_to_add: return
        current_text = self.editor.toPlainText()
        tags_to_append = [tag for tag in tags_to_add if tag not in current_text]
        if not tags_to_append: return
        text_to_add = " ".join(tags_to_append)
        if current_text and not current_text.endswith('\n'): self.editor.append(f"\n\n{text_to_add}")
        else: self.editor.append(f"{text_to_add}")
class PreviewPanel(QWidget):
    linkClicked = pyqtSignal(QUrl)
    homeNoteSelected = pyqtSignal(str, int)
    refreshRequested = pyqtSignal()
    exportRequested = pyqtSignal(str) 
    historyItemSelected = pyqtSignal(object)
    historyRestoreRequested = pyqtSignal(object)
    backRequested = pyqtSignal()
    forwardRequested = pyqtSignal()
    def __init__(self, parent=None):
        super().__init__(parent)
        self.init_ui()
    def init_ui(self):
        layout = QVBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)
        toolbar = QToolBar()
        self.home_button = QToolButton()
        self.home_button.setText("💠")
        self.home_button.setToolTip("ブックのホームノートに移動")
        self.home_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
        home_menu = QMenu(self)
        self.home_button.setMenu(home_menu)
        self.home_button.setEnabled(False)
        toolbar.addWidget(self.home_button)
        self.back_action = QAction(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowBack), "戻る", self)
        self.back_action.setEnabled(False)
        toolbar.addAction(self.back_action)
        self.forward_action = QAction(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowForward), "進む", self)
        self.forward_action.setEnabled(False)
        toolbar.addAction(self.forward_action)
        self.export_button = QToolButton()
        self.export_button.setText("📤")
        self.export_button.setToolTip("エクスポート")
        self.export_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
        self.export_button.setEnabled(False)
        export_menu = QMenu(self)
        export_menu.addAction("as Markdown (.md)").triggered.connect(lambda: self.exportRequested.emit("md"))
        export_menu.addAction("as HTML (.html)").triggered.connect(lambda: self.exportRequested.emit("html"))
        export_menu.addAction("as PDF (.pdf)").triggered.connect(lambda: self.exportRequested.emit("pdf"))
        self.export_button.setMenu(export_menu)
        toolbar.addWidget(self.export_button)
        
        self.address_bar = QLineEdit()
        self.address_bar_action = toolbar.addWidget(self.address_bar)
        self.load_btn = QPushButton("読込")
        self.load_btn_action = toolbar.addWidget(self.load_btn)
        layout.addWidget(toolbar)
        self.author_info_label = QLabel("", self)
        self.author_info_label.setStyleSheet("font-size: 0.8em; color: gray; margin-left: 5px;")
        
        header_layout = QHBoxLayout()
        header_layout.addWidget(self.author_info_label)
        header_layout.addStretch()
        header_widget = QWidget()
        header_widget.setLayout(header_layout)
        layout.addWidget(header_widget)
        self.tabs = QTabWidget()
        self.preview = QWebEngineView()
        
        self.custom_page = CustomWebPage(self)
        self.preview.setPage(self.custom_page)
        self.custom_page.linkClicked.connect(self.linkClicked)
        self.preview.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.preview.customContextMenuRequested.connect(self.show_preview_context_menu)
        # --- ▼▼▼ ここからが修正点です ▼▼▼ ---
        # AppControllerに処理を依頼するため、シグナルを発行するように変更します。
        self.back_action.triggered.connect(self.backRequested.emit)
        self.forward_action.triggered.connect(self.forwardRequested.emit)
        # --- ▲▲▲ 修正ここまで ▲▲▲ ---
        
        self.tabs.addTab(self.preview, "プレビュー")
        self.history_list = QListWidget()
        self.history_list.itemClicked.connect(self.on_history_item_clicked)
        self.history_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.history_list.customContextMenuRequested.connect(self.show_history_context_menu)
        self.tabs.addTab(self.history_list, "履歴")
        
        layout.addWidget(self.tabs)
        self.address_bar.returnPressed.connect(self.navigate_to_url)
        self.load_btn.clicked.connect(self.navigate_to_url)
        self.link_bar_scroll = QScrollArea()
        self.link_bar_scroll.setWidgetResizable(True)
        self.link_bar_scroll.setMaximumHeight(80)
        link_bar_container = QWidget()
        self.link_bar_layout = QHBoxLayout(link_bar_container)
        self.link_bar_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
        self.link_bar_scroll.setWidget(link_bar_container)
        self.link_bar_scroll.setVisible(False)
        layout.addWidget(self.link_bar_scroll)
    def show_preview_context_menu(self, pos):
        menu = QMenu(self)
        page = self.preview.page()
        menu.addAction(page.action(QWebEnginePage.WebAction.Copy))
        menu.addAction(page.action(QWebEnginePage.WebAction.SelectAll))
        menu.addSeparator()
        menu.addAction(page.action(QWebEnginePage.WebAction.Back))
        menu.addAction(page.action(QWebEnginePage.WebAction.Forward))
        menu.addAction(page.action(QWebEnginePage.WebAction.Reload))
        if self.export_button.isEnabled():
            menu.addSeparator()
            export_submenu = QMenu("エクスポート", self)
            export_submenu.addActions(self.export_button.menu().actions())
            menu.addMenu(export_submenu)
        menu.popup(self.preview.mapToGlobal(pos))
    def on_history_item_clicked(self, item: QListWidgetItem):
        history_data = item.data(Qt.ItemDataRole.UserRole)
        if history_data:
            self.historyItemSelected.emit(history_data)
    def show_history_context_menu(self, pos):
        item = self.history_list.itemAt(pos)
        if not item: return
        history_data = item.data(Qt.ItemDataRole.UserRole)
        if not history_data: return
        menu = QMenu()
        restore_action = QAction("このバージョンに復元", self)
        restore_action.triggered.connect(lambda: self.historyRestoreRequested.emit(history_data))
        menu.addAction(restore_action)
        menu.exec(self.history_list.mapToGlobal(pos))
    def update_history_list(self, history_entries):
        self.history_list.clear()
        for entry in history_entries:
            history_id, title, content, saved_at_dt, edited_by = entry 
            if isinstance(saved_at_dt, str):
                saved_at_dt = datetime.fromisoformat(saved_at_dt)
            item = QListWidgetItem(f"{saved_at_dt.strftime('%Y-%m-%d %H:%M:%S')} - {title} (編集者: {edited_by})")
            item.setData(Qt.ItemDataRole.UserRole, entry)
            self.history_list.addItem(item)
    def set_address_bar_visibility(self, visible):
        if hasattr(self, 'address_bar_action'): self.address_bar_action.setVisible(visible); self.load_btn_action.setVisible(visible)
    def navigate_to_url(self):
        url = self.address_bar.text()
        self.preview.setUrl(QUrl('http://' + url if not url.startswith(('http://', 'https://')) else url))
    def render_html(self, html, base_url):
        self.preview.setHtml(html, baseUrl=QUrl.fromLocalFile(os.path.abspath(base_url)))
    def update_link_bar(self, forward_links_data: list, backlinks_data: list, tags: list):
        while self.link_bar_layout.count():
            item = self.link_bar_layout.takeAt(0)
            if item.widget(): item.widget().deleteLater()
        if not forward_links_data and not backlinks_data and not tags:
            self.link_bar_scroll.setVisible(False)
            return
        
        for link in forward_links_data:
            btn = QPushButton(f"🔗 {link['title']}")
            btn.setToolTip(f"[{link['book']}] ID:{link['id']} へのリンク")
            btn.clicked.connect(lambda _, l=link: self.linkClicked.emit(QUrl(f"app://note/{l['book_id']}/{l['id']}")))
            self.link_bar_layout.addWidget(btn)
            
        for t in tags:
            btn = QPushButton(f"🏷️ {t}")
            encoded_tag = quote(t.lstrip('#'))
            btn.clicked.connect(lambda _, tag=encoded_tag: self.linkClicked.emit(QUrl(f"app://tag/{tag}")))
            self.link_bar_layout.addWidget(btn)
            
        for link in backlinks_data:
            btn = QPushButton(f"↩️ {link['title']}")
            btn.setToolTip(f"[{link['source_book']}] ID:{link['id']} からのリンク")
            if 'source_book_id' in link:
                 btn.clicked.connect(lambda _, l=link: self.linkClicked.emit(QUrl(f"app://note/{l['source_book_id']}/{l['id']}")))
            self.link_bar_layout.addWidget(btn)
            
        self.link_bar_layout.addStretch()
        self.link_bar_scroll.setVisible(True)
    def update_home_button(self, home_notes_info: list):
        menu = self.home_button.menu()
        menu.clear()
        if not home_notes_info:
            self.home_button.setEnabled(False)
            return
        sorted_notes = sorted(home_notes_info, key=lambda x: x['book_name'])
        for note in sorted_notes:
            action_text = f"[{note['book_name']}] {note['note_title']}"
            action = QAction(action_text, self)
            action.triggered.connect(lambda _, b=note['book_name'], n=note['note_id']: self.homeNoteSelected.emit(b, n))
            menu.addAction(action)
        self.home_button.setEnabled(True)
class AppController:
    def __init__(self, main_win, db_manager, tree, editor, preview, md_service, settings):
        self.win, self.db_manager, self.tree, self.editor, self.preview, self.md, self.settings = \
            main_win, db_manager, tree, editor, preview, md_service, settings
        
        self.current_book_name = None
        self.current_note_id = None
        self.original_note_content_on_edit_start = None
        
        self.navigation_history = []
        self.navigation_history_pos = -1
        self.is_navigating_history = False
        self.temp_note_id_counter = -1
        try:
            self.current_user = getpass.getuser()
        except Exception:
            self.current_user = "unknown"
        self.original_user = self.current_user # PCのユーザー名を保持
        self.is_admin_logged_in = False
        self.book_lock_states = {book: 'locked_out' for book in db_manager.get_all_book_names()} # 'locked_out', 'pending_lock', 'pending_lockin', 'writable'
        self.lock_timers = {}
        self.db_last_checked_mtime = {}
        self.books_needing_update = set()
        self.db_check_timer = QTimer(self.win)
        self.db_check_timer.timeout.connect(self._check_for_db_updates)
        self._initialize_db_mtimes() 
        
        check_interval_ms = self.settings.get('db_check_interval_seconds', 10) * 1000
        self.db_check_timer.start(check_interval_ms)
        self.lock_check_timer = QTimer(self.win)
        self.lock_check_timer.timeout.connect(self._check_all_held_locks)
        
        lock_check_interval_ms = self.settings.get('lock_check_interval_seconds', 10) * 1000
        self.lock_check_timer.start(lock_check_interval_ms)
        self.preview_update_timer = QTimer(self.win); self.preview_update_timer.setSingleShot(True)
        self.preview_update_timer.setInterval(500); self.preview_update_timer.timeout.connect(self.update_preview)
        self._connect_signals()
    
    def _connect_signals(self):
        self.win.viewChanged.connect(self.tree.set_view_mode)
        self.win.newMemoRequested.connect(self.add_memo_to_first_book)
        self.win.settingsRequested.connect(self.open_settings_and_reload)
        self.win.moveNotesRequested.connect(self.move_notes)
        self.win.pinRequested.connect(self.toggle_pin_current_note)
        self.tree.noteSelected.connect(self.on_note_selected)
        self.tree.addMemoRequested.connect(self.add_memo)
        self.tree.noteActionRequested.connect(self.handle_note_action)
        self.tree.customContextMenuRequested.connect(self.prepare_book_context_menu)
        self.editor.contentModified.connect(self.preview_update_timer.start)
        self.editor.saveRequested.connect(self.save_note_and_update)
        self.editor.internalLinkRequested.connect(self.insert_internal_link)
        self.editor.addTagsRequested.connect(self.show_tag_selection_dialog)
        self.editor.tableRequested.connect(self.show_table_dialog)
        self.editor.pasteImageFromClipboardRequested.connect(self.paste_image_from_clipboard)
        self.preview.linkClicked.connect(self.handle_link_click)
        self.preview.refreshRequested.connect(self.update_preview)
        self.preview.exportRequested.connect(self.export_note)
        self.preview.historyItemSelected.connect(self.on_history_selected)
        self.preview.historyRestoreRequested.connect(self.on_history_restore_requested)
        self.preview.backRequested.connect(self.navigate_back)
        self.preview.forwardRequested.connect(self.navigate_forward)
        self.tree.setHomeNoteRequested.connect(self.set_home_note)
        self.preview.homeNoteSelected.connect(self.jump_to_home_note)
        self.win.loginRequested.connect(self.login)
        self.win.logoutRequested.connect(self.logout)
        self.win.showStatisticsRequested.connect(self.show_statistics)
    def login(self):
        """ログインダイアログを表示し、認証処理を行う"""
        dialog = LoginDialog(self.win)
        if dialog.exec():
            password = dialog.get_password()
            if password == "03920677":
                self.is_admin_logged_in = True
                self.current_user = "ADMIN"
                self.win.update_user_display(self.current_user, self.is_admin_logged_in)
                self.win.show_status_message("管理者としてログインしました。")
            else:
                QMessageBox.warning(self.win, "認証失敗", "パスワードが正しくありません。")
    def logout(self):
        """ログアウト処理を行う"""
        self.is_admin_logged_in = False
        self.current_user = self.original_user
        self.win.update_user_display(self.current_user, self.is_admin_logged_in)
        self.win.show_status_message("ログアウトしました。")
    def show_statistics(self):
        """集計データを取得し、ダイアログで表示する"""
        if not self.is_admin_logged_in:
            return
        
        # DatabaseManagerからランキングデータを取得
        note_counts = self.db_manager.get_note_count_ranking_across_books()
        edit_counts = self.db_manager.get_edit_count_ranking_across_books()
        
        # データを渡してダイアログを表示
        dialog = StatisticsDialog(note_counts, edit_counts, self.win)
        dialog.exec()
    def unload_book(self, book_name: str):
        """指定されたブックをアンロード(接続解除)する"""
        # 1. 閉じようとしているブックが現在編集中の場合、未保存の変更がないか確認
        if self.current_book_name == book_name and self.editor.is_modified():
            reply = QMessageBox.question(self.win, "未保存の変更",
                                         f"ブック「{book_name}」を閉じる前に、現在の変更を保存しますか?",
                                         QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel)
            if reply == QMessageBox.StandardButton.Save:
                if not self.save_note_and_update():
                    return  # 保存に失敗した場合は処理を中断
            elif reply == QMessageBox.StandardButton.Cancel:
                return  # キャンセルされた場合は処理を中断
        # 2. 接続を閉じる
        self.db_manager.close_connection(book_name)
        # 3. アプリケーション内の管理情報から削除
        self.book_lock_states.pop(book_name, None)
        self.db_last_checked_mtime.pop(book_name, None)
        self.books_needing_update.discard(book_name)
        # 4. もし閉じたブックのノートを開いていたら、エディタ等をクリア
        if self.current_book_name == book_name:
            self._clear_current_note_view()
        # 5. ツリー表示を更新して、アンロードされた状態を反映
        self.tree.last_expanded_books = self.tree.get_expanded_books() # ★★★ この行を追加 ★★★
        self.tree.update_view()
        self.win.show_status_message(f"ブック「{book_name}」を閉じました。", 4000)
    def get_lock_file_path(self, book_name):
        model = self.db_manager.get_note_model(book_name)
        if not model: return None
        db_path = model.db_path
        return f"{db_path}.lock"
    def prepare_book_context_menu(self, book_name, menu):
        if book_name in self.books_needing_update:
            unload_action = QAction("ℹ️ ブックを閉じる", self.win)
            unload_action.triggered.connect(lambda: self.unload_book(book_name))
            menu.addAction(unload_action)
            return
        
        is_book_loaded = book_name in self.db_manager.connections
        if is_book_loaded:
            state = self.book_lock_states.get(book_name, 'locked_out')
            if state == 'locked_out':
                lockout_action = QAction("🔒 書き込み許可を取得", self.win)
                lockout_action.triggered.connect(lambda: self.start_lockout(book_name))
                menu.addAction(lockout_action)
            elif state == 'writable':
                lockin_action = QAction("🔓 書き込みを完了", self.win)
                lockin_action.triggered.connect(lambda: self.start_lockin(book_name))
                menu.addAction(lockin_action)
            menu.addSeparator()
            backup_action = QAction("💾 バックアップを作成", self.win)
            backup_action.triggered.connect(lambda: self.create_database_backup(book_name))
            menu.addAction(backup_action)
            menu.addSeparator()
            unload_action = QAction("ℹ️ ブックを閉じる", self.win)
            unload_action.triggered.connect(lambda: self.unload_book(book_name))
            menu.addAction(unload_action)
            menu.addSeparator()
            search_action = QAction("🔍 このブック内を検索", self.win)
            search_action.triggered.connect(lambda: self.start_search_in_book(book_name))
            menu.addAction(search_action)
            menu.addSeparator()
        else:
            load_action = QAction("📖 ブックを読み込む", self.win)
            load_action.triggered.connect(lambda: self.load_single_book(book_name))
            menu.addAction(load_action)
    def load_single_book(self, book_name: str):
        """指定されたブックを手動で読み込み、UIを更新する"""
        if self.db_manager.connect_to_book(book_name):
            # 読み込みに成功したら、ロック状態の辞書にも追加
            self.book_lock_states[book_name] = 'locked_out'
            
            note_model = self.db_manager.get_note_model(book_name)
            if note_model:
                note_model.load_from_db()
                
                # ▼▼▼ 修正点: ここで最終更新日時を台帳に記録します ▼▼▼
                try:
                    if note_model.db_path and os.path.exists(note_model.db_path):
                        # 現在のファイル更新日時を取得し、台帳に保存する
                        self.db_last_checked_mtime[book_name] = os.path.getmtime(note_model.db_path)
                except OSError as e:
                    print(f"Error recording mtime for newly loaded book '{book_name}': {e}")
                # ▲▲▲ 修正ここまで ▲▲▲
            # UIを更新してツリーにノート一覧を表示
            self.tree.last_expanded_books = self.tree.get_expanded_books() # ★★★ この行を追加 ★★★
            self.tree.update_view()
            self.win.show_status_message(f"ブック「{book_name}」を読み込みました。", 4000)
        else:
            QMessageBox.critical(self.win, "読み込みエラー", f"ブック「{book_name}」の読み込みに失敗しました。")
    def start_search_in_book(self, book_name: str):
        """
        指定されたブック名を検索演算子として検索ボックスに設定し、
        検索を開始する。
        """
        # ブック名にスペースが含まれている場合はダブルクォートで囲む
        search_book_name = f'"{book_name}"' if ' ' in book_name else book_name
        
        # 検索ボックスに 'book:ブック名 ' というテキストを設定
        # 末尾にスペースを入れることで、ユーザーが続けてキーワードを入力しやすくする
        self.tree.search_input.setText(f"book:{search_book_name} ")
        
        # 検索ボックスにフォーカスを移し、ユーザーがすぐ入力できるようにする
        self.tree.search_input.setFocus()
        
    def _is_cloud_storage_path(self, path: str) -> bool:
        """指定されたパスがクラウドストレージの同期フォルダ内にある可能性が高いか判定する"""
        if not path:
            return False
        
        # 判定に使用するキーワード(小文字)
        # Google Drive の 'My Drive' や 'Shared Drives' などのパターンを追加
        cloud_keywords = [
            'google drive', 
            'my drive',         # Google Drive for Desktop
            'shared drives',    # Google Drive for Desktop
            'onedrive', 
            'dropbox', 
            'icloud', 
            'com~apple~clouddocs', # macOS iCloud
            'Nextcloud', 
            'box', 
            'mega'
        ]
        
        # パスを小文字に変換して、キーワードが含まれているかチェック
        path_lower = path.lower()
        for keyword in cloud_keywords:
            if keyword in path_lower:
                return True
        return False
    def start_lockout(self, book_name):
        lock_file_path = self.get_lock_file_path(book_name)
        if not lock_file_path: return
        if os.path.exists(lock_file_path):
            owner_user = "不明なユーザー"
            try:
                with open(lock_file_path, 'r') as f:
                    for line in f:
                        if line.strip().startswith('user:'):
                            owner_user = line.strip().split(':', 1)[1]
                            break
            except (IOError, IndexError) as e:
                print(f"Could not read lock file owner from {lock_file_path}: {e}")
            QMessageBox.warning(self.win, "ロックアウト失敗", 
                                f"ブック「{book_name}」はすでにロックされています。\n\n"
                                f"現在のロック保持者: {owner_user}")
            return
        try:
            with open(lock_file_path, 'w') as f:
                f.write(f"user:{self.current_user}\n")
                f.write(f"pid:{os.getpid()}\n")
        except IOError as e:
            QMessageBox.critical(self.win, "エラー", f"ロックファイルの作成に失敗しました:\n{e}")
            return
        note_model = self.db_manager.get_note_model(book_name)
        if not note_model:
            print(f"Error: Could not find model for book '{book_name}' in start_lockout.")
            return
        # DBファイルのパスがクラウドストレージではない(=ローカルファイル)場合
        if not self._is_cloud_storage_path(note_model.db_path):
            # 待機時間なしで、即座にロックを完了させる
            self.book_lock_states[book_name] = 'pending_lock'
            self.tree.last_expanded_books = self.tree.get_expanded_books()
            self.tree.update_view()
            self.update_ui_for_lock_state()
            self.finish_lockout(book_name)
            return
        self.book_lock_states[book_name] = 'pending_lock'
        self.tree.last_expanded_books = self.tree.get_expanded_books()
        self.tree.update_view()
        self.update_ui_for_lock_state()
        duration = self.settings.get('lockout_duration_seconds', 30) * 1000
        self.win.show_status_message(f"「{book_name}」は {duration/1000} 秒後に書き込み可能になります...", 0)
        
        if book_name in self.lock_timers and self.lock_timers[book_name]: self.lock_timers[book_name].stop()
        self.lock_timers[book_name] = QTimer.singleShot(duration, lambda: self.finish_lockout(book_name))
    def finish_lockout(self, book_name):
        lock_file_path = self.get_lock_file_path(book_name)
        if not lock_file_path or not os.path.exists(lock_file_path):
            self.book_lock_states[book_name] = 'locked_out' # ファイルがなければロックアウト状態に戻す
            self.tree.last_expanded_books = self.tree.get_expanded_books() # ★★★ この行を追加 ★★★
            self.tree.update_view()
            self.update_ui_for_lock_state()
            self.win.show_status_message(f"「{book_name}」のロックが予期せず解除されました。")
            self.lock_timers[book_name] = None
            return
        # ロックファイルの所有者を確認
        owner_user = None
        try:
            with open(lock_file_path, 'r') as f:
                line = f.readline().strip()
                if line.startswith('user:'):
                    owner_user = line.split(':', 1)[1]
        except (IOError, IndexError):
            pass
        if owner_user == self.current_user:
            # 自分が所有者なら書き込み可能にする
            self.book_lock_states[book_name] = 'writable'
            self.win.show_status_message(f"「{book_name}」は書き込み可能になりました。")
        else:
            # 他人が所有者ならロックアウトに戻す
            self.book_lock_states[book_name] = 'locked_out'
            self.win.show_status_message(f"「{book_name}」のロックは他のユーザーによって取得されました。")
        self.tree.last_expanded_books = self.tree.get_expanded_books() # ★★★ この行を追加 ★★★
        self.tree.update_view()
        self.update_ui_for_lock_state()
        self.lock_timers[book_name] = None
        
        # もし現在選択中のノートが、ロック解除されたブックのものであれば
        # エディタの読み取り専用状態を即時更新する
        if self.current_book_name == book_name:
            self.editor.editor.setReadOnly(False)
    def start_lockin(self, book_name):
        note_model = self.db_manager.get_note_model(book_name)
        if not note_model:
            print(f"Error: Could not find model for book '{book_name}' in start_lockin.")
            return
        # --- ▼▼▼ ここからが変更点です ▼▼▼ ---
        # DBファイルのパスがクラウドストレージではない(=ローカルファイル)場合
        if not self._is_cloud_storage_path(note_model.db_path):
            # 待機時間なしで、即座にロック解除を完了させる
            self.finish_lockin(book_name)
            return
        
        # --- 以下はクラウドストレージ上のDBファイルに対する通常の処理 ---
        self.book_lock_states[book_name] = 'pending_lockin'
        self.tree.last_expanded_books = self.tree.get_expanded_books()
        self.tree.update_view()
        duration = self.settings.get('lockin_duration_seconds', 30) * 1000
        self.win.show_status_message(f"「{book_name}」の書き込みを完了し、{duration/1000} 秒後にロックされます...", 0)
        
        if book_name in self.lock_timers and self.lock_timers[book_name]: self.lock_timers[book_name].stop()
        self.lock_timers[book_name] = QTimer.singleShot(duration, lambda: self.finish_lockin(book_name))
    def finish_lockin(self, book_name):
        lock_file_path = self.get_lock_file_path(book_name)
        if os.path.exists(lock_file_path):
            try:
                os.remove(lock_file_path)
            except OSError as e:
                print(f"Error removing lock file {lock_file_path}: {e}")
        
        self.book_lock_states[book_name] = 'locked_out'
        self.tree.last_expanded_books = self.tree.get_expanded_books() # ★★★ この行を追加 ★★★
        self.tree.update_view()
        self.update_ui_for_lock_state()
        self.win.show_status_message(f"「{book_name}」は再びロックされました。")
        self.lock_timers[book_name] = None
        
        # もし現在選択中のノートが、ロックされたブックのものであれば
        # エディタの読み取り専用状態を即時更新する
        if self.current_book_name == book_name:
            self.editor.editor.setReadOnly(True)
    def update_ui_for_lock_state(self):
        """現在のノートが属するブックのロック状態に応じてUIを更新する"""
        is_writable = False
        is_new_unsaved_note_active = self.current_note_id is not None and self.current_note_id < 0
        if not is_new_unsaved_note_active and self.current_book_name:
            state = self.book_lock_states.get(self.current_book_name, 'locked_out')
            is_writable = (state == 'writable')
        elif is_new_unsaved_note_active:
            # 未保存の新規メモの場合は、常に書き込み可能
            is_writable = True
        # --- 「新規メモ」ボタンの有効/無効判定 ---
        can_create_new_memo = (not is_new_unsaved_note_active) and \
                              any(s == 'writable' for s in self.book_lock_states.values())
        self.win.new_memo_action.setEnabled(can_create_new_memo)
        
        # ▼▼▼ START OF MODIFICATION ▼▼▼
        # --- 「移動」ボタンの有効/無効判定 ---
        # 未保存の新規メモを編集中ではない場合のみ、ボタンを有効にする
        self.win.move_notes_action.setEnabled(not is_new_unsaved_note_active)
        # ▲▲▲ END OF MODIFICATION ▲▲▲
        
        # エディタのテキスト入力エリアのReadOnly状態を直接制御
        self.editor.editor.setReadOnly(not is_writable)
        # ツールバーのアクションを個別に制御
        for tb in [self.editor.toolbar_basic, self.editor.toolbar_optional, self.editor.toolbar_advanced]:
            for action in tb.actions():
                # 保存ボタン以外のアクションを制御
                if action.text() != "💾 保存":
                    action.setEnabled(is_writable)
        
        # 保存ボタンは特別に制御
        save_action = next((a for a in self.editor.toolbar_basic.actions() if a.text() == "💾 保存"), None)
        if save_action:
            save_action.setEnabled(is_writable)
    def check_books_writable(self, book_names: list[str]) -> bool:
        for book_name in book_names:
            state = self.book_lock_states.get(book_name, 'locked_out')
            if state != 'writable':
                QMessageBox.warning(self.win, "書き込み不可", f"ブック「{book_name}」は現在書き込みができません。\n「書き込み許可を取得」処理を行ってください。")
                return False
        return True
    def verify_lock_ownership(self, book_name: str) -> bool:
        lock_file_path = self.get_lock_file_path(book_name)
        if not lock_file_path or not os.path.exists(lock_file_path):
            QMessageBox.warning(self.win, "書き込みエラー", f"ブック「{book_name}」の書き込みロックが見つかりません。")
            # --- UIを自動更新 ---
            self.book_lock_states[book_name] = 'locked_out'
            self.tree.update_view()
            self.update_ui_for_lock_state()
            # --------------------
            return False
        owner_user = "不明なユーザー" # デフォルト値を設定
        try:
            with open(lock_file_path, 'r') as f:
                line = f.readline().strip()
                if line.startswith('user:'):
                    owner_user = line.split(':', 1)[1]
        except (IOError, IndexError):
            pass
        if owner_user != self.current_user:
            # ★★★ ここからが修正の核 ★★★
            QMessageBox.warning(self.win, "書き込みエラー", 
                                f"ブック「{book_name}」のロックは他のユーザーによって取得されています。\n\n"
                                f"現在のロック保持者: {owner_user}")
            
            # 物理的なロックファイルには触らず、UIの状態のみを現在の事実に合わせる
            self.book_lock_states[book_name] = 'locked_out'
            self.tree.update_view()
            self.update_ui_for_lock_state()
            # ★★★ 修正ここまで ★★★
            return False
        
        return True
    def show_table_dialog(self):
        dialog = TableDialog(self.win)
        if dialog.exec():
            markdown_table = dialog.get_markdown_table()
            self.editor.editor.textCursor().insertText(f"\n{markdown_table}\n")
    def on_note_selected(self, book_name, note_id):
        # ケース1:現在アクティブなノートが「未保存の新規メモ」である場合の特別処理
        if self.current_note_id and self.current_note_id < 0 and \
           (self.current_book_name != book_name or self.current_note_id != note_id):
            
            reply = QMessageBox.question(self.win, "未保存の新規メモ",
                                         "新規メモが保存されていません。保存しますか?",
                                         QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel)
            if reply == QMessageBox.StandardButton.Save:
                if not self.save_note_and_update():
                    self.tree.blockSignals(True)
                    self.tree.select_item_by_id(self.current_book_name, self.current_note_id)
                    self.tree.blockSignals(False)
                    return
            elif reply == QMessageBox.StandardButton.Cancel:
                self.tree.blockSignals(True)
                self.tree.select_item_by_id(self.current_book_name, self.current_note_id)
                self.tree.blockSignals(False)
                return
            
            # ▼▼▼ START OF MODIFICATION ▼▼▼
            elif reply == QMessageBox.StandardButton.Discard:
                # 破棄対象のノート情報をローカル変数に保存
                book_to_discard_from = self.current_book_name
                note_id_to_discard = self.current_note_id
                # --- UI更新を遅延させるための関数を定義 ---
                def delayed_discard_and_proceed():
                    # 1. データモデルから一時的なメモのデータを削除する
                    note_model = self.db_manager.get_note_model(book_to_discard_from)
                    if note_model:
                        item_to_remove = next((child for child in note_model.root_item.children if child.db_id == note_id_to_discard), None)
                        if item_to_remove:
                            note_model.root_item.remove_child(item_to_remove)
                    
                    # 2. ナビゲーション履歴からも一時的なメモを削除する
                    temp_note_context = (book_to_discard_from, note_id_to_discard)
                    self.navigation_history = [item for item in self.navigation_history if item != temp_note_context]
                    self.navigation_history_pos = len(self.navigation_history) - 1
                    # 3. コントローラーの状態をリセットする
                    #    (この時点では self.current... はまだ古い値のまま)
                    self.current_note_id = None
                    self.current_book_name = None
                    self.editor.set_modified(False)
                    
                    # 4. ツリー表示を更新して、画面から項目を削除する
                    self.tree.update_view()
                    # 5. 本来の目的であったノートの選択処理を改めて実行する
                    #    これで新しいノートが安全に読み込まれる
                    self.on_note_selected(book_name, note_id)
                # --- 安全措置と遅延実行 ---
                # 1. クラッシュを防ぐため、ツリー表示からフォーカスを外す
                self.tree.tree_view.clearFocus()
                # 2. 現在のイベント処理が完了した後でUI更新を実行する
                QTimer.singleShot(0, delayed_discard_and_proceed)
                
                # 遅延実行するので、ここでは何もせずにメソッドを終了する
                return
            # ▲▲▲ END OF MODIFICATION ▲▲▲
        # ケース2:既存の「保存済みノート」に対する変更確認ロジック
        elif self.editor.is_modified() and self.current_note_id and self.current_note_id > 0:
            reply = QMessageBox.question(self.win, "未保存の変更", "変更を保存しますか?", QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel)
            if reply == QMessageBox.StandardButton.Save:
                if not self.save_note_and_update(): return
            elif reply == QMessageBox.StandardButton.Cancel:
                self.tree.blockSignals(True); self.tree.select_item_by_id(self.current_book_name, self.current_note_id); self.tree.blockSignals(False); return
        
        # --- 状態遷移の実行 ---
        # (以降のコードは変更なし)
        if not self.is_navigating_history:
            if self.navigation_history_pos < len(self.navigation_history) - 1:
                self.navigation_history = self.navigation_history[:self.navigation_history_pos + 1]
            
            if not self.navigation_history or self.navigation_history[-1] != (book_name, note_id):
                self.navigation_history.append((book_name, note_id))
                self.navigation_history_pos += 1
        self.current_book_name = book_name
        self.current_note_id = note_id
        
        if note_id < 0: # 新規・未保存メモの場合
            note_model = self.db_manager.get_note_model(book_name)
            temp_item = next((child for child in note_model.root_item.children if child.db_id == note_id), None)
            title = temp_item.name if temp_item else "新規メモ"
            content = f"# {title}\n\n"
            self.editor.set_content(content)
            self.win.set_status_id(book_name, "UNSAVED")
            self.editor.setEnabled(True)
            self.preview.export_button.setEnabled(False)
            self.win.update_pin_button_state(False)
            self.preview.update_history_list([])
            self.preview.author_info_label.setText("") 
        else: # 保存済みノートの場合
            self.win.set_status_id(book_name, note_id)
            note_model = self.db_manager.get_note_model(book_name)
            if note_model:
                full_note_data = note_model.get_full_note_data(note_id)
                if full_note_data:
                    content = full_note_data['content']
                    created_by = full_note_data.get('created_by', 'unknown')
                    last_edited_by = full_note_data.get('last_edited_by', 'unknown')
                    updated_at = full_note_data.get('updated_at')
                    self.editor.set_content(content)
                    self.editor.setEnabled(True)
                    self.preview.export_button.setEnabled(True)
                    self.original_note_content_on_edit_start = content
                    
                    pinned_notes = self.settings.get('pinned_notes', [])
                    is_pinned = {"book": book_name, "id": note_id} in pinned_notes
                    self.win.update_pin_button_state(is_pinned)
                    history = note_model.get_note_history(note_id)
                    self.preview.update_history_list(history)
                    
                    updated_at_str = updated_at.strftime('%Y-%m-%d %H:%M') if updated_at else "N/A"
                    self.preview.author_info_label.setText(
                        f"作成者: {created_by} | 最終編集者: {last_edited_by}"
                    )
                else: 
                    self._clear_current_note_view()
            else: 
                self._clear_current_note_view()
        
        self.update_preview()
        self.update_history_buttons()
        self.update_ui_for_lock_state()
    def navigate_back(self):
        if self.navigation_history_pos > 0:
            self.is_navigating_history = True
            self.navigation_history_pos -= 1
            book, note_id = self.navigation_history[self.navigation_history_pos]
            self.on_note_selected(book, note_id)
            self.tree.select_item_by_id(book, note_id)
            self.is_navigating_history = False
    def navigate_forward(self):
        if self.navigation_history_pos < len(self.navigation_history) - 1:
            self.is_navigating_history = True
            self.navigation_history_pos += 1
            book, note_id = self.navigation_history[self.navigation_history_pos]
            self.on_note_selected(book, note_id)
            self.tree.select_item_by_id(book, note_id)
            self.is_navigating_history = False
    def update_history_buttons(self):
        can_go_back = self.navigation_history_pos > 0
        can_go_forward = self.navigation_history_pos < len(self.navigation_history) - 1
        self.preview.back_action.setEnabled(can_go_back)
        self.preview.forward_action.setEnabled(can_go_forward)
    def on_history_selected(self, history_entry):
        if not self.current_note_id: return
        
        current_content = self.editor.editor.toPlainText()
        # ★★★ 修正点 ★★★
        # history_entry には5つの値が含まれているため、5つの変数で受け取るように修正します。
        # このメソッドでは edited_by は使用しないため、アンダースコア(_)で受け取ります。
        # history_id, title, history_content, saved_at_dt = history_entry  <- 修正前
        history_id, title, history_content, saved_at_dt, _ = history_entry # <- 修正後
        if isinstance(saved_at_dt, str):
            saved_at_dt = datetime.fromisoformat(saved_at_dt)
        diff = difflib.HtmlDiff(tabsize=4, wrapcolumn=80).make_file(
            current_content.splitlines(keepends=True),
            history_content.splitlines(keepends=True),
            fromdesc="現在の内容",
            todesc=f"履歴 ({saved_at_dt.strftime('%Y-%m-%d %H:%M:%S')})"
        )
        
        self.preview.tabs.setCurrentIndex(0) 
        
        full_html = f"<!DOCTYPE html><html><head><meta charset='utf-8'>{self.md.get_preview_css()}</head><body>{diff}</body></html>"
        db_path = self.db_manager.get_note_model(self.current_book_name).db_path
        self.preview.render_html(full_html, os.path.dirname(db_path))
    def on_history_restore_requested(self, history_entry):
        history_id, title, history_content, saved_at, _ = history_entry
        
        reply = QMessageBox.question(self.win, "履歴の復元",
            f"エディタの内容を {saved_at} のバージョンに戻しますか?\n"
            "この操作は元に戻せません(保存するまでは)。",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No)
        if reply == QMessageBox.StandardButton.Yes:
            self.editor.set_content(history_content)
            self.win.show_status_message(f"履歴から復元しました。変更を保存してください。")
    def save_note_and_update(self):
        if self.save_note(): 
            self.update_preview()
            note_model = self.db_manager.get_note_model(self.current_book_name)
            if note_model and self.current_note_id:
                history = note_model.get_note_history(self.current_note_id)
                self.preview.update_history_list(history)
            return True
        return False
            
    def save_note(self):
        if not self.current_book_name or not self.current_note_id:
            QMessageBox.warning(self.win, "保存エラー", "ノートが選択されていません。")
            return False
            
        state = self.book_lock_states.get(self.current_book_name, 'locked_out')
        if state != 'writable':
            QMessageBox.warning(self.win, "書き込み不可", f"ブック「{self.current_book_name}」は現在書き込みができません。\n「書き込み許可を取得」処理を行ってください。")
            return False
        if not self.verify_lock_ownership(self.current_book_name):
            return False
        note_model = self.db_manager.get_note_model(self.current_book_name)
        if not note_model: return False
        content = self.editor.editor.toPlainText()
        new_title = self._extract_title(content)
        
        if new_title is None:
            QMessageBox.warning(self.win, "保存エラー", "ノートのタイトル(一行目)が空です。\nタイトルを入力してから保存してください。")
            return False
        
        if self.current_note_id < 0:
            new_db_id = note_model.add_item(new_title, is_folder=False, content=content)
            if not new_db_id:
                QMessageBox.critical(self.win, "エラー", "新規メモの保存に失敗しました。")
                return False
            
            old_temp_id = self.current_note_id
            self.current_note_id = new_db_id
            for i, (b, n_id) in enumerate(self.navigation_history):
                if b == self.current_book_name and n_id == old_temp_id:
                    self.navigation_history[i] = (self.current_book_name, new_db_id)
                    break
        else:
            if not note_model.update_content(self.current_note_id, new_title, content):
                QMessageBox.critical(self.win, "エラー", "保存に失敗しました。"); return False
            
        link_targets = []
        cleaned = note_model._clean_content_for_link_extraction(content)
        matches = re.finditer(r'\[.*?\]\((?:(?P<book>[\w\d_-]+):)?(?P<id>\d+)\)', cleaned)
        all_book_ids = self.db_manager.get_all_note_ids_by_book()
        my_book_id = self.db_manager.book_name_to_id_map.get(self.current_book_name)
        for m in matches:
            target_book_id = m.group("book") or my_book_id
            target_id = int(m.group("id"))
            target_book_name = self.db_manager.book_id_to_name_map.get(target_book_id)
            
            if target_book_name and target_book_name in all_book_ids and target_id in all_book_ids[target_book_name]:
                 # ★★★ 修正: book_id も渡す ★★★
                 link_targets.append({"id": target_id, "book_id": target_book_id})
        note_model.update_links_for_note(self.current_note_id, link_targets)
            
        tags = re.findall(r'(?<!\S)#([\w\d_/-]+)', note_model._clean_content_for_tags(content))
        note_model.update_tags_for_note(self.current_note_id, tags)
        self.win.show_status_message(f"保存しました: [{self.current_book_name}] ID:{self.current_note_id}")
        self.editor.set_modified(False)
        self.original_note_content_on_edit_start = content
        
        # ★★★ ここからが追加部分です ★★★
        try:
            # 保存したブックのモデルからDBファイルのパスを取得
            db_path = note_model.db_path
            if db_path and os.path.exists(db_path):
                # ファイルの現在の最終更新日時を取得
                new_mtime = os.path.getmtime(db_path)
                # 監視システムが記憶している日時を更新する
                self.db_last_checked_mtime[self.current_book_name] = new_mtime
                
                # もし更新マークが表示されていたら、ここで消す
                if self.current_book_name in self.books_needing_update:
                    self.books_needing_update.discard(self.current_book_name)
                    # UIの再描画が必要なため、tree.update_view()を再度呼ぶか、
                    # この後の処理に任せる。ここでは下の処理で再描画されるのでOK。
                    
        except OSError as e:
            print(f"Error updating mtime after save: {e}")
        # ★★★ 追加部分ここまで ★★★
        note_model.load_from_db()
        self.tree.update_view()
        QTimer.singleShot(50, lambda: self.tree.select_item_by_id(self.current_book_name, self.current_note_id))
        return True
        
    def update_preview(self):
        if not self.current_book_name or not self.current_note_id:
            self.preview.preview.setHtml(self.md.get_themed_empty_page())
            self.preview.update_link_bar([], [], [])
            self.preview.update_history_list([])
            self.win.set_status_id("N/A", None)
            return
        self.preview.tabs.setTabText(0, "プレビュー")
        md_text = self.editor.editor.toPlainText()
        html_body = self.md.to_html(md_text, highlight_terms=self.tree.search_input.text().split())
        # ★★★ ここからが変更点 ★★★
        # リンク切れを判定し、CSSクラスを付与する処理
        def check_and_mark_links(match):
            a_tag = match.group(0)
            book_id_match = re.search(r'data-book="([^"]+)"', a_tag)
            if book_id_match:
                book_id = book_id_match.group(1)
                
                # ブックIDからブック名を取得
                book_name = self.db_manager.book_id_to_name_map.get(book_id)
                # ブック名がそもそも設定に存在しないか、またはブックが現在読み込まれていない場合
                if not book_name or book_name not in self.db_manager.connections:
                    # 既存の "internal-link" クラスに "broken-link" を追加
                    return a_tag.replace('class="internal-link"', 'class="internal-link broken-link"')
            return a_tag
        # internal-linkクラスを持つ全ての<a>タグに対して判定処理を実行
        html_body = re.sub(r'<a[^>]*class="internal-link"[^>]*>', check_and_mark_links, html_body)
        # ★★★ 変更ここまで ★★★
        full_html = f"<!DOCTYPE html><html><head><meta charset='utf-8'>{self.md.get_preview_css()}</head><body>{html_body}</body></html>"
        
        note_model = self.db_manager.get_note_model(self.current_book_name)
        if note_model:
            db_path = note_model.db_path
            self.preview.render_html(full_html, os.path.dirname(db_path))
            if self.current_note_id > 0:
                fwd_links_raw = note_model.get_forward_links_for_note(self.current_note_id)
                fwd_links = []
                for l in fwd_links_raw:
                    # ★★★ 修正箇所: 正しいキー 'book_id' を使用 ★★★
                    book_id = l["book_id"] 
                    book_display_name = self.db_manager.book_id_to_name_map.get(book_id)
                    
                    if book_display_name:
                        note_title = self.db_manager.get_note_title(book_display_name, l["id"])
                        fwd_links.append({
                            "id": l["id"],
                            "book": book_display_name, # ツールチップ表示用
                            "title": note_title,
                            "book_id": book_id # URL生成用
                        })
                
                all_backlinks = []
                current_book_id = self.db_manager.book_name_to_id_map.get(self.current_book_name)
                if current_book_id:
                    for book_name, model in self.db_manager.connections.items():
                        # ★★★ 修正箇所: get_backlinks_for_note に book_id を渡す ★★★
                        backlinks_in_book = model.get_backlinks_for_note(self.current_note_id, current_book_id)
                        source_book_id = self.db_manager.book_name_to_id_map.get(book_name)
                        if source_book_id:
                            for b in backlinks_in_book:
                                b['source_book'] = book_name
                                b['source_book_id'] = source_book_id
                            all_backlinks.extend(backlinks_in_book)
                # NoteTreeModelと全く同じ正規表現で「名前のみ」を抽出
                tag_names = re.findall(r'(?<=[ \t])#(?!#)([\w\d_/-]+)', note_model._clean_content_for_tags(md_text))
            
                # 表示用に '#' を付け直す
                tags_with_hash = [f"#{name}" for name in tag_names]
                self.preview.update_link_bar(fwd_links, all_backlinks, sorted(list(set(tags_with_hash))))
            else:
                self.preview.update_link_bar([], [], [])
    def handle_link_click(self, url: QUrl):
        try:
            if url.scheme() == 'app':
                host, path = url.host(), url.path().strip('/')
                if host == 'note':
                    parts = path.split('/')
                    
                    if len(parts) > 1:
                        # ★★★ 修正箇所: parts[0] は book_id として解釈
                        book_id = '/'.join(parts[:-1]) # パスに/が含まれる場合も考慮
                        note_id = int(parts[-1])
                        # book_id から実際のブック名を取得
                        book_name = self.db_manager.book_id_to_name_map.get(book_id)
                    else:
                        book_name = self.current_book_name
                        note_id = int(parts[0]) # ★★★ 修正: parts[0] を使用
                    if book_name and self.db_manager.get_note_model(book_name):
                        self.on_note_selected(book_name, note_id)
                        self.tree.select_item_by_id(book_name, note_id)
                
                elif host == 'tag':
                    decoded_tag = unquote(path)
                    self.tree.search_input.setText(f'"#{decoded_tag}"')
            
            else:
                QDesktopServices.openUrl(url)
        except (ValueError, IndexError, KeyError) as e: # ★★★ KeyError を追加
            QMessageBox.warning(self.win, "リンクエラー", f"無効な内部リンクです: {url.toString()}\nエラー: {e}")
    def add_memo_to_first_book(self):
        books = self.db_manager.get_all_book_names()
        if books:
            first_writable_book = next((b for b in books if self.book_lock_states.get(b) == 'writable'), None)
            if first_writable_book:
                self.add_memo(first_writable_book)
            else:
                QMessageBox.warning(self.win, "書き込み不可", "書き込み可能なブックがありません。\nいずれかのブックで「書き込み許可を取得」してください。")
        else:
            QMessageBox.warning(self.win, "エラー", "メモを追加するブックがありません。設定からブックを追加してください。")
            
    def add_memo(self, book_name):
        state = self.book_lock_states.get(book_name, 'locked_out')
        if state != 'writable':
            QMessageBox.warning(self.win, "書き込み不可", f"ブック「{book_name}」は現在書き込みができません。\n「書き込み許可を取得」処理を行ってください。")
            return
        note_model = self.db_manager.get_note_model(book_name)
        if not note_model: return
        
        num = note_model.get_next_new_memo_number()
        title = f"新規メモ{num}"
        
        temp_id = self.temp_note_id_counter
        self.temp_note_id_counter -= 1
        
        # 1. ツリーに項目を視覚的に追加する
        self.tree.add_new_item_to_view(book_name, temp_id, title)
        
        # 2. 状態遷移の全ロジックを持つ on_note_selected を呼び出す
        #    (ここでコントローラの状態を直接変更しないことが重要)
        self.on_note_selected(book_name, temp_id)
    def handle_note_action(self, action_type: str, book_name: str, note_id: int, index: QModelIndex):
        if action_type == "delete":
            self.delete_note(book_name, note_id, index)
        elif action_type == "restore":
            self.restore_note(book_name, note_id) # 復元時は選択位置の考慮不要
        elif action_type == "permanent_delete":
            self.permanently_delete_note(book_name, note_id, index)
    def restore_note(self, book_name, note_id):
        if not self.check_books_writable([book_name]):
            return
            
        note_model = self.db_manager.get_note_model(book_name)
        if not note_model: return
        if note_model.restore_note(note_id):
            self.win.show_status_message(f"ノートを復元しました: [{book_name}] ID:{note_id}")
            note_model.load_from_db()
            self.tree.update_view()
        else:
            QMessageBox.critical(self.win, "エラー", "ノートの復元に失敗しました。")
    def permanently_delete_note(self, book_name, note_id):
        if not self.check_books_writable([book_name]):
            return
        reply = QMessageBox.warning(self.win, "最終確認",
            "このノートを完全に削除します。\nこの操作は元に戻すことができません。よろしいですか?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
            QMessageBox.StandardButton.Cancel)
        if reply != QMessageBox.StandardButton.Yes:
            return
        note_model = self.db_manager.get_note_model(book_name)
        if not note_model: return
        
        if note_model.permanently_delete_item(note_id):
            self.win.show_status_message(f"ノートを完全に削除しました: [{book_name}] ID:{note_id}")
            note_model.load_from_db()
            self.tree.update_view()
        else:
            QMessageBox.critical(self.win, "エラー", "ノートの完全な削除に失敗しました。")
    def delete_note(self, book_name, note_id, index: QModelIndex):
        if not self.check_books_writable([book_name]):
            return
        
        note_model = self.db_manager.get_note_model(book_name)
        if not note_model: return
        
        note_title = self.db_manager.get_note_title(book_name, note_id)
        reply = QMessageBox.question(self.win, "メモの削除", f"本当にメモを削除しますか?\n\n[{book_name}] {note_title}",
                                     QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                                     QMessageBox.StandardButton.No)
        if reply != QMessageBox.StandardButton.Yes:
            return
        if note_model.delete_item(note_id):
            self.win.show_status_message(f"メモを削除しました: [{book_name}] ID:{note_id}")
            item_to_remove = next((child for child in note_model.root_item.children if child.db_id == note_id), None)
            if item_to_remove:
                note_model.root_item.remove_child(item_to_remove)
            parent_index = index.parent()
            row = index.row()
            # 1. DBからデータを削除した後、ビューモデルからも直接行を削除する
            #    これにより、ツリー全体を再描画する必要がなくなり、他のインデックスが無効になるのを防ぐ
            self.tree.tree_model.removeRow(row, parent_index)
            # 2. 次に選択すべきインデックスを、更新されたビューモデルから計算する
            next_index_to_select = QModelIndex()
            parent_item = self.tree.tree_model.itemFromIndex(parent_index)
            if parent_item and parent_item.rowCount() > 0:
                # 削除した行が最後の行だった場合は、新しい最後の行を選択
                # そうでなければ、同じ行番号(削除によって一つずれたアイテム)を選択
                new_row = min(row, parent_item.rowCount() - 1)
                next_index_to_select = self.tree.tree_model.index(new_row, 0, parent_index)
            self._update_mtime_record(book_name) # ファイル監視のための日時更新は継続
            # もし現在開いていたノートが削除されたものなら、ビューをクリア
            if self.current_book_name == book_name and self.current_note_id == note_id:
                self._clear_current_note_view()
            
            # 3. 決定したアイテムを選択する
            if next_index_to_select.isValid():
                # QTimerを使い、現在のイベント処理が終わってから選択を実行
                QTimer.singleShot(10, lambda: self.tree.select_item_at_index(next_index_to_select))
            else:
                # 選択するアイテムがなければ、UIの状態を更新
                self.update_ui_for_lock_state()
            # --- ▲▲▲ ロジックここまで ▲▲▲ ---
        else:
            QMessageBox.critical(self.win, "エラー", "メモの削除に失敗しました。")
    def _extract_title(self, content):
        lines = content.strip().split('\n')
        if not lines: return None # 空の場合はNoneを返す
        cleaned = re.sub(r'^[#\s]+|(\*\*|\*|~~|\+\+|`|\[|\]|\(|\))', '', lines[0]).strip()
        return cleaned[:80] if cleaned else None
    def insert_internal_link(self):
        # ▼▼▼ 変更点: 第2引数に self.settings を追加します ▼▼▼
        dlg = SelectItemDialog(self.db_manager, self.settings, "内部リンク先のノートを選択", parent=self.win)
        if dlg.exec():
            book_name, note_id, note_name = dlg.get_selected_item_info()
            if book_name and note_id:
                book_id = self.db_manager.book_name_to_id_map.get(book_name)
                if book_id:
                    cur = self.editor.editor.textCursor()
                    cur.insertText(f"[{cur.selectedText() or note_name}]({book_id}:{note_id})")
    def toggle_pin_current_note(self):
        if not self.current_book_name or not self.current_note_id or self.current_note_id < 0:
            return
        note_context = {"book": self.current_book_name, "id": self.current_note_id}
        pinned_notes = self.settings.get('pinned_notes', [])
        
        is_currently_pinned = note_context in pinned_notes
        if is_currently_pinned:
            pinned_notes.remove(note_context)
            self.win.show_status_message("ピン留めを解除しました")
        else:
            pinned_notes.append(note_context)
            self.win.show_status_message("ノートをピン留めしました")
        
        self.settings.set('pinned_notes', pinned_notes)
        self.settings.save()
        self.win.update_pin_button_state(not is_currently_pinned)
        self.tree.update_view()
        QTimer.singleShot(50, lambda: self.tree.select_item_by_id(self.current_book_name, self.current_note_id))
    def move_notes(self, notes_to_move: list[dict]):
        if not notes_to_move:
                return
        # 移動対象のノートが含まれるブック(移動元)に更新マークがないかチェック
        source_books = {note['book_name'] for note in notes_to_move}
        for book_name in source_books:
            if book_name in self.books_needing_update:
                QMessageBox.warning(self.win, "移動不可", f"移動元のブック「{book_name}」が外部で更新されています。\n一度ブックを閉じて開き直すなどして、更新を反映させてから再度お試しください。")
                return
        # 移動対象のノートをブックごとにグループ化
        grouped_by_book = collections.defaultdict(list)
        for note in notes_to_move:
                grouped_by_book[note['book_name']].append(note['note_id'])
        # 移動元となる全てのブックが書き込み可能かチェック
        if not self.check_books_writable(list(grouped_by_book.keys())):
                return
        # ロックの所有権も確認
        for book in grouped_by_book.keys():
                if not self.verify_lock_ownership(book):
                        return
        # 更新マークが表示されているブックを移動先候補から除外する
        all_available_books = [b for b in self.db_manager.get_all_book_names() if b not in self.books_needing_update]
        total_moved_count = 0
        
        # ★★★ 追加:更新対象となるブック名を収集するセット ★★★
        books_to_update = set()
        
        # ブックごとに移動処理を実行
        for source_book, note_ids in grouped_by_book.items():
                # フィルタリング済みのリストをダイアログに渡す
                dialog = SelectDestinationBookDialog(all_available_books, source_book, self.win)
                if dialog.exec():
                        destination_book = dialog.get_selected_book()
                        if not destination_book:
                                continue
                        
                        # ★★★ 追加:移動元と移動先をセットに追加 ★★★
                        books_to_update.add(source_book)
                        books_to_update.add(destination_book)
                        # DatabaseManagerに移動処理を委譲
                        try:
                                success, count = self.db_manager.move_notes_to_book(source_book, destination_book, note_ids)
                                if success:
                                        total_moved_count += count
                                else:
                                        QMessageBox.critical(self.win, "エラー", f"「{source_book}」からのメモ移動中にエラーが発生しました。")
                        except Exception as e:
                                QMessageBox.critical(self.win, "重大なエラー", f"メモの移動に失敗しました:\n{e}")
        
        if total_moved_count > 0:
            QMessageBox.information(self.win, "成功", f"{total_moved_count}件のメモを移動しました。")
        # =================================================================
        # ▼▼▼ ここからが変更点です ▼▼▼
        # =================================================================
        # 以前は全ブックのリンクを再構築していましたが、
        # ここでは移動元と移動先のブックのみを対象に処理を行います。
        if books_to_update:
            self.win.show_status_message("リンク索引を更新しています...", 0)
            
            # リンク再構築に必要な全ノートIDのマップを一度だけ取得
            all_ids = self.db_manager.get_all_note_ids_by_book()
            rebuild_success = True
            
            # 収集したブック(移動元と移動先)に対してのみリンク再構築を実行
            for book_name in books_to_update:
                model = self.db_manager.get_note_model(book_name)
                if model:
                    # 個別のブックモデルの rebuild_links_table を直接呼び出す
                    success, _, _ = model.rebuild_links_table(
                        all_ids, 
                        self.db_manager.book_name_to_id_map, 
                        self.db_manager.book_id_to_name_map
                    )
                    if not success:
                        rebuild_success = False
                        print(f"Failed to rebuild links for book: {book_name}")
            self.win.statusBar().clearMessage() # ステータスバーのメッセージを消す
            if not rebuild_success:
                QMessageBox.warning(self.win, "警告", "リンク索引の更新に一部失敗しました。")
            # 関連ブックのファイル更新日時とモデルを更新
            for book_name in books_to_update:
                self._update_mtime_record(book_name)
                model = self.db_manager.get_note_model(book_name)
                if model:
                    model.load_from_db()
        # =================================================================
        # ▲▲▲ 変更ここまで ▲▲▲
        # =================================================================
        # ツリービューは最後に一度だけ更新
        self.tree.last_expanded_books = self.tree.get_expanded_books()
        self.tree.update_view()
        
        # 移動したノートに現在開いているノートが含まれているかチェック
        moved_ids_from_current_book = {n['note_id'] for n in notes_to_move if n['book_name'] == self.current_book_name}
        if self.current_note_id in moved_ids_from_current_book:
                # 含まれている場合、現在のノート表示をリセット
                self._clear_current_note_view()
        # 移動処理の最後に、現在の状態に基づいてプレビューを必ず更新します。
        self.update_preview()
    def show_tag_selection_dialog(self):
        all_tags = self.db_manager.get_all_tags_across_books()
        if not all_tags: QMessageBox.information(self.win, "情報", "登録されているタグがありません。"); return
        dialog = TagSelectionDialog(all_tags, self.win)
        if dialog.exec(): self.editor.add_tags(dialog.get_selected_tags())
    def open_settings_and_reload(self):
        dlg = SettingsDialog(self.settings, self.win)
        dlg.rebuildLinksRequested.connect(self.rebuild_links); dlg.rebuildTagsRequested.connect(self.rebuild_tags)
        
        old_connections = list(self.settings.get('db_connections', {}).items())
        old_icons = self.settings.get('book_icons', {})
        # ▼▼▼ 追加: 古いソート設定を記憶 ▼▼▼
        old_sort_setting = self.settings.get('sort_by_title', False)
        if dlg.exec():
            self.settings.settings = dlg.get_settings()
            self.settings.save()
            
            new_connections = list(self.settings.get('db_connections', {}).items())
            new_icons = self.settings.get('book_icons', {})
            # ▼▼▼ 追加: 新しいソート設定を取得 ▼▼▼
            new_sort_setting = self.settings.get('sort_by_title', False)
            if old_connections != new_connections:
                QMessageBox.information(self.win, "設定変更", "データベース接続の設定が変更されました。アプリケーションを再起動して反映します。")
                QTimer.singleShot(0, self.win.close)
            else:
                # ▼▼▼ ここからが変更点です ▼▼▼
                ui_needs_update = False
                if old_icons != new_icons:
                    ui_needs_update = True
                
                # ソート設定が変更されたかチェック
                if old_sort_setting != new_sort_setting:
                    self.tree.sort_by_title = new_sort_setting # NoteTreePanelのプロパティを更新
                    ui_needs_update = True
                # UIの更新が必要な場合のみ、update_viewを呼び出す
                if ui_needs_update:
                    self.tree.update_view()
                # ▲▲▲ 変更ここまで ▲▲▲
                
                self.win.editor_panel.update_toolbar_visibility()
                self.win.update_view_toolbar()
                QMessageBox.information(self.win, "設定変更", "設定が保存されました。一部の設定は再起動後に反映されます。")
    def rebuild_links(self):
        all_books = self.db_manager.get_all_book_names()
        if not self.check_books_writable(all_books):
            return
        # 戻り値を正しく3つの変数で受け取るように修正
        success, link_count, updated_notes_count = self.db_manager.rebuild_all_links()
        
        if success:
            # 全てのブックが変更された可能性があるので、全てのブックの日時を更新する
            for book_name in all_books:
                self._update_mtime_record(book_name)
                                
            # メッセージボックスの表示も、前回提案した詳細なものに戻す
            QMessageBox.information(self.win, "成功",
                f"リンクの再構築と修復が完了しました。\n\n"
                f"・構築された有効なリンク数: {link_count}件\n"
                f"・本文が自動修復されたノート数: {updated_notes_count}件")
            
            # 現在開いているノートの本文が変更された可能性があるので、
            # データベースから最新の内容を再読み込みしてUIに反映する
            if self.current_book_name and self.current_note_id:
                note_model = self.db_manager.get_note_model(self.current_book_name)
                if note_model:
                    content = note_model.get_content(self.current_note_id)
                    self.editor.set_content(content)
            
            # プレビューも更新
            self.update_preview()
        else:
            QMessageBox.critical(self.win, "エラー", "リンクの再構築中にエラーが発生しました。")
    def rebuild_tags(self):
        all_books = self.db_manager.get_all_book_names()
        if not self.check_books_writable(all_books):
            return
        success, count = self.db_manager.rebuild_all_tags()
        if success: QMessageBox.information(self.win, "成功", f"{count}件のタグを再構築しました。")
        else: QMessageBox.critical(self.win, "エラー", "タグの再構築中にエラーが発生しました。")
# AppController クラス内
    def export_note(self, file_format):
        if not self.current_note_id or self.current_note_id < 0: return
        
        note_model = self.db_manager.get_note_model(self.current_book_name)
        if not note_model: return
        
        content = self.editor.editor.toPlainText()
        title = self._extract_title(content)
        safe_filename = re.sub(r'[\\/*?:"<>|]', '_', title)
        filters = {"md": "Markdown (*.md)", "html": "HTML (*.html)", "pdf": "PDF (*.pdf)"}
        path, _ = QFileDialog.getSaveFileName(self.win, "名前を付けて保存", f"{safe_filename}.{file_format}", filters[file_format])
        if not path: return
        # エクスポート中は誤操作を防ぐため、一時的にUIの一部を無効化すると親切ですが、
        # ここではステータスバーでの通知にとどめます。
        
        try:
            if file_format == 'md':
                # 1. エディタから全てのテキストを取得
                full_content = self.editor.editor.toPlainText()
                
                # 2. 既にファイル名生成のために抽出済みの「クリーンなタイトル」を使用
                #    (title 変数はこのコードブロックの前に定義されています)
                
                if not title:
                    # 万が一タイトルが取得できない場合は、元の内容をそのまま書き出す
                    final_content_for_md = full_content
                    QMessageBox.warning(self.win, "エクスポート警告", "有効なタイトルが見つからなかったため、内容はそのままエクスポートされます。")
                else:
                    # 3. 本文(2行目以降)を取得
                    lines = full_content.split('\n', 1)
                    body_content = lines[1] if len(lines) > 1 else ""
                    # 4. 「# タイトル」の行と本文を結合して、最終的な内容を生成
                    #    タイトルと本文の間に空行を1つ入れて、体裁を整えます
                    final_content_for_md = f"# {title}\n\n{body_content.lstrip()}"
                # 5. 生成した内容をファイルに書き込む
                with open(path, 'w', encoding='utf-8') as f:
                    f.write(final_content_for_md)
                
                self.win.show_status_message(f"エクスポート完了: {os.path.basename(path)}", 3000)
            elif file_format == 'html':
                # 目次なしHTMLを生成して保存
                html_body = self.md.to_html(content, include_toc=False)
                full_html = f"<!DOCTYPE html><html><head><meta charset='utf-8'>{self.md.get_preview_css()}</head><body>{html_body}</body></html>"
                with open(path, 'w', encoding='utf-8') as f: f.write(full_html)
                self.win.show_status_message(f"エクスポート完了: {os.path.basename(path)}", 3000)
            # ▼▼▼ PDF出力ロジックの修正 (PyQt6対応) ▼▼▼
            elif file_format == 'pdf':
                self.win.show_status_message("PDF生成の準備中...", 0)
                page = self.preview.preview.page()
                base_path = os.path.dirname(note_model.db_path)
                # 1. 目次なしHTML用のデータを準備
                html_body_for_pdf = self.md.to_html(content, include_toc=False)
                full_html_for_pdf = f"<!DOCTYPE html><html><head><meta charset='utf-8'>{self.md.get_preview_css()}</head><body>{html_body_for_pdf}</body></html>"
                # --- 非同期処理用の内部関数を定義 ---
                # 【フェーズ3】印刷完了時の処理
                def on_pdf_finished(file_path_str, success):
                    # シグナル接続を解除(一度きりの実行にするため)
                    try: page.pdfPrintingFinished.disconnect(on_pdf_finished)
                    except TypeError: pass
                    
                    if success:
                        self.win.show_status_message("PDFのエクスポートが完了しました。", 4000)
                    else:
                        QMessageBox.warning(self.win, "エラー", "PDFファイルの保存に失敗しました。\nファイルが開かれているか、書き込み権限がない可能性があります。")
                        self.win.statusBar().clearMessage()
                    
                    # 最後に必ず通常のプレビュー表示(目次あり)に戻す
                    self.update_preview()
                # 【フェーズ2】HTMLレンダリング完了時の処理 -> 印刷実行
                def on_load_finished(success):
                    # シグナル接続を解除
                    try: page.loadFinished.disconnect(on_load_finished)
                    except TypeError: pass
                    if not success:
                        QMessageBox.warning(self.win, "エラー", "印刷用ページのレンダリングに失敗しました。")
                        self.update_preview() # 元に戻す
                        return
                    
                    self.win.show_status_message("PDFに出力中...", 0)
                    
                    # 印刷完了シグナルに完了時処理を接続
                    page.pdfPrintingFinished.connect(on_pdf_finished)
                    
                    # ★★★ PyQt6での正しい呼び出し方 ★★★
                    # 引数はファイルパスのみ。コールバックは渡さない。
                    page.printToPdf(path)
                # --- 処理フローの開始 ---
                # 1. HTML読み込み完了シグナルにフェーズ2の処理を接続
                page.loadFinished.connect(on_load_finished)
                # 2. プレビューの内容を、一時的に目次なしのHTMLに差し替える
                # これにより非同期でレンダリングが始まり、完了後に loadFinished が発火する
                self.preview.render_html(full_html_for_pdf, base_path)
            
            # ▲▲▲ 修正ここまで ▲▲▲
        except Exception as e: 
            QMessageBox.critical(self.win, "エクスポートエラー", f"予期せぬエラーが発生しました:\n{e}")
            # エラー時は念のためプレビューを元に戻す
            if file_format == 'pdf':
                self.update_preview()
    def cleanup_lock_files(self):
        """アプリケーション終了時に、自身が保持しているロックファイルを削除する"""
        print("Cleaning up lock files on exit...")
        for book_name, state in self.book_lock_states.items():
            # このインスタンスが書き込み可能、または書き込み待機中のブックのみを対象とする
            if state in ['writable', 'pending_lock']:
                lock_file_path = self.get_lock_file_path(book_name)
                if lock_file_path and os.path.exists(lock_file_path):
                    try:
                        # 念のため、本当に自分が所有者であるか最終確認する
                        owner_user = ""
                        with open(lock_file_path, 'r') as f:
                            line = f.readline().strip()
                            if line.startswith('user:'):
                                owner_user = line.split(':', 1)[1]
                        
                        if owner_user == self.current_user:
                            print(f"Removing lock file for '{book_name}': {lock_file_path}")
                            os.remove(lock_file_path)
                        else:
                            # 万が一、自分のものでない場合は削除しない
                            print(f"Skipping lock file for '{book_name}' - owned by another user.")
                    except Exception as e:
                        # エラーが発生してもアプリの終了は妨げない
                        print(f"Error removing lock file {lock_file_path}: {e}")
    def _check_all_held_locks(self):
        """
        自身が書き込み可能('writable')だと思っているブックのロックが
        本当に維持されているか定期的に確認する。
        """
        # self.book_lock_states はループ中に変更される可能性があるので、コピーに対してループする
        for book_name, state in list(self.book_lock_states.items()):
            if state == 'writable':
                lock_lost = False
                current_owner = None
                lock_file_path = self.get_lock_file_path(book_name)
                # ケース1: ロックファイルが消えている
                if not lock_file_path or not os.path.exists(lock_file_path):
                    lock_lost = True
                else:
                    # ケース2: ロックファイルはあるが、所有者が自分ではない
                    try:
                        with open(lock_file_path, 'r') as f:
                            line = f.readline().strip()
                            if line.startswith('user:'):
                                current_owner = line.split(':', 1)[1]
                    except Exception as e:
                        print(f"Error checking lock owner for {book_name}: {e}")
                        lock_lost = True # 読めない場合もロック喪失とみなす
                    if not lock_lost and current_owner != self.current_user:
                        lock_lost = True
                if lock_lost:
                    # ロックが失われていたので、UIを更新する
                    self.win.show_status_message(f"「{book_name}」の書き込みロックが外部で解除されました。表示を更新します。", 6000)
                    self.book_lock_states[book_name] = 'locked_out'
                    self.tree.update_view()
                    self.update_ui_for_lock_state()
    def create_database_backup(self, book_name_to_backup: str):
        note_model = self.db_manager.get_note_model(book_name_to_backup)
        if not note_model or not note_model.db_path:
            QMessageBox.critical(self.win, "エラー", f"ブック「{book_name_to_backup}」のデータベースパスが見つかりません。")
            return
        db_path = note_model.db_path
        db_dir = os.path.dirname(db_path)
        db_filename = os.path.basename(db_path)
        
        # 1. backupフォルダのパスを決定し、なければ作成する
        backup_folder = os.path.join(db_dir, "backup")
        try:
            os.makedirs(backup_folder, exist_ok=True)
        except OSError as e:
            QMessageBox.critical(self.win, "バックアップエラー", f"backupフォルダの作成に失敗しました:\n{e}")
            return
            
        # 2. バックアップファイル名を決定する (年月-連番.db)
        # 例: my_notes_202509-1.db
        today = datetime.now()
        base_backup_name = f"{os.path.splitext(db_filename)[0]}_{today.strftime('%Y%m')}"
        
        # 既存のバックアップファイルを確認し、次の連番を決定する
        i = 1
        while True:
            backup_filename = f"{base_backup_name}-{i}.db"
            backup_path = os.path.join(backup_folder, backup_filename)
            if not os.path.exists(backup_path):
                break
            i += 1
            
        # 3. データベースファイルをコピーする
        try:
            # データベース接続が有効な場合、WALモードのチェックポイント処理を行うとより安全
            if note_model.conn:
                note_model.conn.execute("PRAGMA wal_checkpoint(TRUNCATE);")
            shutil.copy2(db_path, backup_path)
            
            # WALモードの場合、関連ファイルもコピーする
            wal_file = f"{db_path}-wal"
            shm_file = f"{db_path}-shm"
            if os.path.exists(wal_file):
                shutil.copy2(wal_file, f"{backup_path}-wal")
            if os.path.exists(shm_file):
                shutil.copy2(shm_file, f"{backup_path}-shm")
            QMessageBox.information(self.win, "成功", 
                                    f"バックアップを作成しました。\n\n"
                                    f"保存先: {backup_path}")
        except Exception as e:
            QMessageBox.critical(self.win, "バックアップエラー", f"ファイルのコピー中にエラーが発生しました:\n{e}")
            
    def _initialize_db_mtimes(self):
        """起動時に各DBの最終更新日時を記録する"""
        # ▼▼▼ START OF MODIFICATION ▼▼▼
        # 読み込まれているブック(接続が確立しているブック)のみを対象にする
        for book_name, model in self.db_manager.connections.items():
            if model.db_path and os.path.exists(model.db_path):
                try:
                    self.db_last_checked_mtime[book_name] = os.path.getmtime(model.db_path)
                except OSError:
                    self.db_last_checked_mtime[book_name] = 0
    # ★★★ 追加: DBの更新を定期的にチェックするメソッド ★★★
    def _check_for_db_updates(self):
        """タイマーによって呼び出され、DBファイルの更新をチェックする"""
        needs_ui_update = False
        for book_name, model in self.db_manager.connections.items():
            if model.db_path and os.path.exists(model.db_path):
                try:
                    current_mtime = os.path.getmtime(model.db_path)
                    last_mtime = self.db_last_checked_mtime.get(book_name, 0)
                    if current_mtime > last_mtime and book_name not in self.books_needing_update:
                        print(f"Update detected for book: {book_name}")
                        self.books_needing_update.add(book_name)
                        needs_ui_update = True
                except OSError:
                    continue
        
        if needs_ui_update:
            self.win.show_status_message("外部で更新されたブックがあります。", 5000)
            self.tree.update_view() # アイコンを変更するためにツリーを更新
    def update_book(self, book_name: str):
        """
        指定されたブックのデータを安全に再読み込みし、UIを更新する。
        開いていたノートが削除された場合も考慮する、より堅牢な実装。
        """
        note_model = self.db_manager.get_note_model(book_name)
        if not note_model:
            return
        is_current_note_affected = (self.current_book_name == book_name)
        
        # もし更新対象のブックにあるノートを編集中で、未保存の変更があるなら確認
        if is_current_note_affected and self.editor.is_modified():
            reply = QMessageBox.question(self.win, "確認",
                f"現在編集中のノートを含むブック「{book_name}」を更新します。\n"
                "最新の内容を読み込みますか? (現在の編集内容は破棄されます)",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.No)
            if reply == QMessageBox.StandardButton.No:
                # ユーザーがキャンセルした場合、更新自体を中止する
                return
        # 以前に選択されていたノートIDを一時的に保持
        previous_note_id = self.current_note_id if is_current_note_affected else None
        # 1. データをデータベースから再読み込み
        note_model.load_from_db()
        # 2. 以前開いていたノートが、再読み込み後も存在するかを確認
        note_still_exists = False
        if previous_note_id:
            # find_item_by_db_id は TreeItem を返す。存在すればTrueと評価される
            note_still_exists = bool(note_model.find_item_by_db_id(previous_note_id))
        # 3. ファイル監視の状態を更新
        self._update_mtime_record(book_name)
        # 4. ツリービュー全体を再描画
        self.tree.last_expanded_books = self.tree.get_expanded_books()
        self.tree.update_view()
        # 5. 状態に応じてエディタ/プレビューを更新
        if note_still_exists:
            # ノートが存在する場合: ツリーで再選択し、内容を再表示
            self.tree.select_item_by_id(book_name, previous_note_id)
            self.on_note_selected(book_name, previous_note_id)
        elif is_current_note_affected:
            # ノートが削除されていた場合: エディタを安全にクリア
            self.win.show_status_message(f"開いていたノートは外部で削除されました。", 5000)
            self._clear_current_note_view()
        self.win.show_status_message(f"ブック「{book_name}」を更新しました。", 4000)
    def _clear_current_note_view(self):
        """現在のノート選択を解除し、エディタとプレビューを安全にクリアする"""
        self.current_book_name = None
        self.current_note_id = None
        self.editor.set_content("")
        self.editor.setEnabled(False)
        self.editor.set_modified(False)
        self.preview.export_button.setEnabled(False)
        self.win.update_pin_button_state(False)
        self.win.set_status_id("N/A", None)
        self.update_preview() # This will show an empty page
    def _update_mtime_record(self, book_name: str):
        """
        指定されたブックの最終更新日時を内部記録に反映させ、
        「更新が必要」状態を解除するヘルパーメソッド。
        """
        note_model = self.db_manager.get_note_model(book_name)
        if not note_model or not note_model.db_path:
            return
            
        try:
            # データベースファイルが存在するか確認
            if os.path.exists(note_model.db_path):
                # ファイルの現在の最終更新日時を取得
                new_mtime = os.path.getmtime(note_model.db_path)
                # 監視システムが記憶している日時を、たった今取得した日時に更新
                self.db_last_checked_mtime[book_name] = new_mtime
                # もし「更新が必要」マークがついていたら、それを取り除く
                self.books_needing_update.discard(book_name)
        except OSError as e:
            # ファイルアクセスでエラーが起きてもアプリは止めない
            print(f"Error in _update_mtime_record for {book_name}: {e}")
    # ▼▼▼ このメソッドをまるごと追加します ▼▼▼
    def _clear_current_note_view(self):
        """現在のノート選択を解除し、エディタとプレビューを安全にクリアする"""
        self.current_book_name = None
        self.current_note_id = None
        self.editor.set_content("")
        self.editor.setEnabled(False)
        self.editor.set_modified(False)
        self.preview.export_button.setEnabled(False)
        self.win.update_pin_button_state(False)
        self.win.set_status_id("N/A", None)
        self.update_preview() # This will show an empty page
    def set_home_note(self, book_name, note_id):
        """NoteTreePanelからの要求に応じて、ブックのホームノートを設定・保存する"""
        if not self.check_books_writable([book_name]):
            return
            
        home_notes = self.settings.get('home_notes_by_book', {})
        home_notes[book_name] = note_id
        self.settings.set('home_notes_by_book', home_notes)
        self.settings.save()
        
        self.win.show_status_message(f"ブック「{book_name}」のホームノートを設定しました。")
        self.update_home_button_view() # UIを即時更新
        def delayed_ui_update():
            """UIの更新処理をまとめた関数"""
            # 1. 左側のノート一覧ツリーを再描画して、アイコンの変更を画面に反映させます。
            self.tree.update_view()
            # 2. 再描画によって選択が外れるのを防ぐため、今操作したノートを再び選択状態にします。
            self.tree.select_item_by_id(book_name, note_id)
        # QTimer.singleShotを使い、現在のイベント処理が完了した直後にUI更新を実行します。
        # これにより、描画が確実に行われるようになります。
        QTimer.singleShot(0, delayed_ui_update)
        
    def jump_to_home_note(self, book_name, note_id):
        """PreviewPanelからの要求に応じて、指定されたホームノートにジャンプする"""
        note_model = self.db_manager.get_note_model(book_name)
        # ノートが存在するか確認
        if not note_model or not note_model.find_item_by_db_id(note_id):
            QMessageBox.warning(self.win, "エラー", "ホームノートが見つかりません。削除された可能性があります。")
            # 見つからない場合は設定から自動的に削除する
            home_notes = self.settings.get('home_notes_by_book', {})
            if home_notes.pop(book_name, None):
                self.settings.set('home_notes_by_book', home_notes)
                self.settings.save()
                self.update_home_button_view()
            return
        self.on_note_selected(book_name, note_id)
        self.tree.select_item_by_id(book_name, note_id)
    def update_home_button_view(self):
        """設定からホームノート情報を取得し、PreviewPanelのボタン表示を更新する"""
        home_notes_map = self.settings.get('home_notes_by_book', {})
        home_notes_info = []
        
        # 現在読み込まれているブックのホームノートのみをメニューに表示する
        for book_name in self.db_manager.get_all_book_names():
            if book_name in home_notes_map:
                note_id = home_notes_map[book_name]
                note_title = self.db_manager.get_note_title(book_name, note_id)
                
                home_notes_info.append({
                    'book_name': book_name,
                    'note_id': note_id,
                    'note_title': note_title,
                })
                
        self.preview.update_home_button(home_notes_info)
    def paste_image_from_clipboard(self):
        """
        クリップボードに画像があれば、設定されたフォルダにユニークな名前で保存し、
        そのMarkdownリンクをエディタに挿入する。
        """
        # 1. 事前チェック
        if not self.current_book_name or not self.current_note_id:
            QMessageBox.warning(self.win, "エラー", "画像を挿入するノートを開いてください。")
            return
        folder_path = self.settings.get('image_paste_folder')
        if not folder_path:
            QMessageBox.warning(self.win, "設定エラー", 
                                "画像の保存先フォルダが設定されていません。\n"
                                "設定画面の「エディタとプレビュー」タブで指定してください。")
            return
        try:
            if not os.path.isdir(folder_path):
                # フォルダが存在しない場合は作成を試みる
                os.makedirs(folder_path, exist_ok=True)
        except OSError as e:
            QMessageBox.critical(self.win, "フォルダエラー", f"保存先フォルダの作成に失敗しました:\n{e}")
            return
        clipboard = QApplication.clipboard()
        if not clipboard.mimeData().hasImage():
            QMessageBox.information(self.win, "情報", "クリップボードに画像データがありません。")
            return
        # 2. ファイル名生成 (例: 251002_1.jpg)
        safe_username = re.sub(r'\W+', '_', self.current_user)
        today_str = datetime.now().strftime('%y%m%d')
        # プレフィックスにユーザー名を追加
        base_filename_prefix = f"{today_str}_{safe_username}_"
        max_num = 0
        try:
            for filename in os.listdir(folder_path):
                if filename.startswith(base_filename_prefix):
                    # ファイル名からプレフィックスと拡張子を除いた部分を取得
                    numeric_part_str = os.path.splitext(filename)[0][len(base_filename_prefix):]
                    if numeric_part_str.isdigit():
                        max_num = max(max_num, int(numeric_part_str))
        except OSError as e:
             QMessageBox.critical(self.win, "ファイルエラー", f"保存先フォルダのスキャンに失敗しました:\n{e}")
             return
        new_num = max_num + 1
        new_filename = f"{base_filename_prefix}{new_num}.jpg"
        full_path = os.path.join(folder_path, new_filename)
        # 3. 画像保存
        image = clipboard.image() # QImageオブジェクトを取得
        if image.isNull():
            QMessageBox.critical(self.win, "エラー", "クリップボードから画像を取得できませんでした。")
            return
        # JPEG形式 (品質90) で保存
        if not image.save(full_path, "JPG", 90):
            QMessageBox.critical(self.win, "保存エラー", f"画像の保存に失敗しました:\n{full_path}")
            return
        # 4. Markdownリンクをエディタに挿入
        # QUrl.fromLocalFile を使うことで、OSに依存しない正しいファイルURLを生成できる
        image_url = QUrl.fromLocalFile(full_path).toString()
        markdown_link = f"\n![{new_filename}]({image_url})\n"
        self.editor.editor.textCursor().insertText(markdown_link)
        self.win.show_status_message(f"画像を保存し、リンクを挿入しました: {new_filename}", 5000)
# =================================================================
# 8. MAIN WINDOW
# =================================================================
class MainWindow(QMainWindow):
    viewChanged = pyqtSignal(str); newMemoRequested = pyqtSignal(); settingsRequested = pyqtSignal()
    moveNotesRequested = pyqtSignal(list)
    pinRequested = pyqtSignal()
    loginRequested = pyqtSignal()
    logoutRequested = pyqtSignal()
    showStatisticsRequested = pyqtSignal()
# MainWindow クラス内
    def __init__(self, db_manager, settings_manager, parent=None):
        super().__init__(parent)
        self.db_manager = db_manager; self.settings_manager = settings_manager
        
        # ▼▼▼ 設定読み込み部分を修正 ▼▼▼
        layout_settings = self.settings_manager.get('window_layout', {})
        self.preview_visible = layout_settings.get('preview_visible', True)
        self.editor_visible = layout_settings.get('editor_visible', True)
        self.last_right_splitter_sizes = None
        
        self.perform_tag_search_button = QPushButton("タグ 🔍")
        self.init_ui()
        self.restore_window_state()
    # ★★★ ここからが修正箇所です ★★★
    # init_uiよりも前に、そこで参照されるメソッドを定義します
    def on_view_changed(self, action):
        view_id = action.data()
        self.move_notes_action.setEnabled(view_id == 'chrono')
        if view_id != 'chrono' and self.move_notes_action.isChecked():
            self.move_notes_action.setChecked(False)
            self.on_move_mode_toggled(False)
        self.viewChanged.emit(view_id)
# MainWindow クラス内
    def toggle_editor_panel_visibility(self):
        is_hiding = self.editor_panel.isVisible()
        
        if is_hiding:
            # ▼▼▼ ここからが修正点です ▼▼▼
            # ボタンが押された瞬間の、もう片方(プレビュー)の表示状態を先に確認します。
            is_preview_visible_at_start = self.preview_panel.isVisible()
            # ケース1: 両方のパネルが表示されている状態から、エディタを非表示にする場合。
            # この時だけ、2パネルのサイズ比率を記憶します。
            if is_preview_visible_at_start:
                self.last_right_splitter_sizes = self.right_splitter.sizes()
            
            # ケース2: プレビューが既に非表示の状態で、最後のエディタを非表示にしようとした場合。
            # この場合は「入れ替え」動作とし、プレビューを再表示します。
            else: 
                self.preview_panel.setVisible(True)
                self.preview_visible = True
                self.preview_toggle_action.setChecked(False)
                self.preview_toggle_action.setText("プレビュー非表示")
            # 最終的に、エディタは必ず非表示にします。
            self.editor_panel.setVisible(False)
            # ▲▲▲ 修正ここまで ▲▲▲
        else:
            # 表示する際のロジック (変更なし)
            is_preview_visible = self.preview_panel.isVisible()
            self.editor_panel.setVisible(True)
            
            if is_preview_visible and self.last_right_splitter_sizes:
                self.right_splitter.setSizes(self.last_right_splitter_sizes)
        # ボタンのテキストと状態変数を更新
        self.editor_visible = self.editor_panel.isVisible()
        self.editor_toggle_action.setChecked(not self.editor_visible)
        self.editor_toggle_action.setText("エディタ表示" if not self.editor_visible else "エディタ非表示")
    def toggle_preview_panel_visibility(self):
        is_hiding = self.preview_panel.isVisible()
        
        if is_hiding:
            # ▼▼▼ ここからが修正点です ▼▼▼
            # ボタンが押された瞬間の、もう片方(エディタ)の表示状態を先に確認します。
            is_editor_visible_at_start = self.editor_panel.isVisible()
            # ケース1: 両方のパネルが表示されている状態から、プレビューを非表示にする場合。
            # この時だけ、2パネルのサイズ比率を記憶します。
            if is_editor_visible_at_start:
                self.last_right_splitter_sizes = self.right_splitter.sizes()
            # ケース2: エディタが既に非表示の状態で、最後のプレビューを非表示にしようとした場合。
            # この場合は「入れ替え」動作とし、エディタを再表示します。
            else:
                self.editor_panel.setVisible(True)
                self.editor_visible = True
                self.editor_toggle_action.setChecked(False)
                self.editor_toggle_action.setText("エディタ非表示")
            
            # 最終的に、プレビューは必ず非表示にします。
            self.preview_panel.setVisible(False)
            # ▲▲▲ 修正ここまで ▲▲▲
        else:
            # 表示する際のロジック (変更なし)
            is_editor_visible = self.editor_panel.isVisible()
            self.preview_panel.setVisible(True)
            
            if is_editor_visible and self.last_right_splitter_sizes:
                self.right_splitter.setSizes(self.last_right_splitter_sizes)
        
        # ボタンのテキストと状態変数を更新
        self.preview_visible = self.preview_panel.isVisible()
        self.preview_toggle_action.setChecked(not self.preview_visible)
        self.preview_toggle_action.setText("プレビュー表示" if not self.preview_visible else "プレビュー非表示")
    def on_move_mode_toggled(self, checked):
        if checked:
            self.tree_panel.set_move_mode(True)
            self.move_notes_action.setText("✅ 移動を実行")
            self.new_memo_action.setEnabled(False)
            self.editor_toggle_action.setEnabled(False)
            self.show_status_message("移動するノートを選択し、「移動を実行」をクリックしてください。", timeout=0)
        else:
            checked_notes = self.tree_panel.get_checked_notes()
            def reset_ui_to_normal_mode():
                self.statusBar().clearMessage()
                self.move_notes_action.setText("→ 移動")
                self.new_memo_action.setEnabled(any(s == 'writable' for s in self.controller.book_lock_states.values()))
                self.editor_toggle_action.setEnabled(True)
                
                self.tree_panel.update_view()
            QTimer.singleShot(0, reset_ui_to_normal_mode)
            if checked_notes:
                self.moveNotesRequested.emit(checked_notes)
            else:
                self.show_status_message("ノートの移動をキャンセルしました。")
    
    # ★★★ init_uiメソッドは、これらのメソッドの後に配置します ★★★
    def init_ui(self):
        self.setWindowTitle(APP_NAME); self.setGeometry(100, 100, 1400, 800)
        self.view_toolbar = QToolBar("Views"); self.view_toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
        self.view_action_group = QActionGroup(self); self.view_action_group.setExclusive(True)
        self.view_action_group.triggered.connect(self.on_view_changed)
        
        self.tree_panel = NoteTreePanel(self.db_manager, self.settings_manager, self.view_toolbar, self.perform_tag_search_button)
        
        self.editor_panel = EditorPanel(self.settings_manager)
        self.preview_panel = PreviewPanel()
        self.right_splitter = QSplitter(Qt.Orientation.Horizontal)
        self.right_splitter.addWidget(self.editor_panel)
        self.right_splitter.addWidget(self.preview_panel)
        self.main_splitter = QSplitter(Qt.Orientation.Horizontal)
        self.main_splitter.addWidget(self.tree_panel)
        self.main_splitter.addWidget(self.right_splitter)
        self.main_splitter.setStretchFactor(0, 0)
        self.main_splitter.setStretchFactor(1, 1)
        self.setCentralWidget(self.main_splitter)
        
        top_toolbar = QToolBar("Actions"); self.addToolBar(Qt.ToolBarArea.TopToolBarArea, top_toolbar)
 
        self.new_memo_action = QAction("📄 新規メモ", self, triggered=self.newMemoRequested.emit)
        top_toolbar.addAction(self.new_memo_action)
        self.pin_action = QAction("📌 ピン留め", self, toolTip="このノートをピン留め/解除", triggered=self.pinRequested.emit, checkable=True)
        top_toolbar.addAction(self.pin_action)
        top_toolbar.addSeparator()
        self.move_notes_action = QAction("→ 移動", self, triggered=self.on_move_mode_toggled, checkable=True)
        top_toolbar.addAction(self.move_notes_action)
        top_toolbar.addSeparator()
        top_toolbar.addAction(QAction("⚙️ 設定", self, triggered=self.settingsRequested.emit))
        top_toolbar.addSeparator()
        self.stats_action = QAction("📊 集計", self, triggered=self.showStatisticsRequested.emit)
        self.stats_action.setVisible(False)
        top_toolbar.addAction(self.stats_action)
        
        spacer = QWidget()
        spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
        top_toolbar.addWidget(spacer)
        self.editor_toggle_action = QAction("エディタ非表示", self, triggered=self.toggle_editor_panel_visibility, checkable=True)
        top_toolbar.addAction(self.editor_toggle_action)
        self.preview_toggle_action = QAction("プレビュー非表示", self, triggered=self.toggle_preview_panel_visibility, checkable=True)
        top_toolbar.addAction(self.preview_toggle_action)
        try:
            username = getpass.getuser()
        except Exception:
            username = "unknown"
            
        self.user_info_label = QLabel(f"👤: {username}", self)
        self.user_info_label.setStyleSheet("padding-right: 8px;")
        # 右クリックメニューを有効にする
        self.user_info_label.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.user_info_label.customContextMenuRequested.connect(self.show_user_context_menu)
        top_toolbar.addWidget(self.user_info_label)
        editor_toggle_button = top_toolbar.widgetForAction(self.editor_toggle_action)
        if editor_toggle_button:
            editor_toggle_button.setObjectName("editorToggleButton")
        # --- ▼▼▼ ここからが追加部分です ▼▼▼ ---
        preview_toggle_button = top_toolbar.widgetForAction(self.preview_toggle_action)
        if preview_toggle_button:
            preview_toggle_button.setObjectName("previewToggleButton")
        # --- ▲▲▲ 追加ここまで ▲▲▲ ---
        self.id_status_label = QLabel("ID: N/A")
        self.statusBar().addPermanentWidget(self.id_status_label)
        self.update_view_toolbar()
    def show_user_context_menu(self, pos):
        """ユーザー名ラベルの右クリックメニューを表示する"""
        menu = QMenu()
        # コントローラーが管理するログイン状態に応じてメニュー項目を切り替え
        if self.controller.is_admin_logged_in:
            logout_act = menu.addAction("ログアウト")
            logout_act.triggered.connect(self.logoutRequested.emit)
        else:
            login_act = menu.addAction("管理者としてログイン")
            login_act.triggered.connect(self.loginRequested.emit)
        
        menu.exec(self.user_info_label.mapToGlobal(pos))
    
    def update_user_display(self, username, is_admin):
        """ユーザー名の表示を更新し、集計ボタンの表示/非表示を切り替える"""
        self.user_info_label.setText(f"👤: {username}")
        self.stats_action.setVisible(is_admin)
    def update_view_toolbar(self):
        for action in self.view_action_group.actions(): self.view_action_group.removeAction(action)
        self.view_toolbar.clear()
        views_to_create = collections.OrderedDict()
        views_to_create['chrono'] = 'All Notes'
        views_to_create['tags'] = 'タグ'
        views_to_create['links'] = 'リンク'
        if self.settings_manager.get('show_trash_view', False):
                views_to_create['trash'] = 'ゴミ箱'
        action_chrono = QAction('All Notes', self, checkable=True); action_chrono.setData('chrono')
        self.view_toolbar.addAction(action_chrono); self.view_action_group.addAction(action_chrono)
        action_tags = QAction('タグ', self, checkable=True); action_tags.setData('tags')
        self.view_toolbar.addAction(action_tags); self.view_action_group.addAction(action_tags)
        tag_search_action = self.view_toolbar.addWidget(self.perform_tag_search_button)
        tag_search_action.setVisible(False)
        self.tree_panel.set_tag_search_action(tag_search_action)
        action_links = QAction('リンク', self, checkable=True); action_links.setData('links')
        self.view_toolbar.addAction(action_links); self.view_action_group.addAction(action_links)
        
        if 'trash' in views_to_create:
                action_trash = QAction('ゴミ箱', self, checkable=True); action_trash.setData('trash')
                self.view_toolbar.addAction(action_trash); self.view_action_group.addAction(action_trash)
                
        last_view = self.settings_manager.get('last_view_mode', 'chrono')
        action_to_select = next((a for a in self.view_action_group.actions() if a.data() == last_view), None)
        
        if not action_to_select: 
                action_to_select = self.view_action_group.actions()[0]
                self.settings_manager.set('last_view_mode', action_to_select.data())
        action_to_select.setChecked(True)
        self.on_view_changed(action_to_select)
    def update_pin_button_state(self, is_pinned: bool):
        self.pin_action.setChecked(is_pinned)
    def show_status_message(self, message, timeout=4000):
        self.statusBar().showMessage(message, timeout)
    def set_status_id(self, book_name, item_id):
        self.id_status_label.setText(f"{book_name} | ID: {item_id if item_id else 'N/A'}")
        
    def restore_window_state(self):
        layout = self.settings_manager.get('window_layout')
        if layout:
            win_geom = layout.get('main_window')
            if win_geom:
                if win_geom.get('is_maximized'):
                    self.showMaximized()
                elif all(k in win_geom for k in ['x', 'y', 'width', 'height']):
                    self.setGeometry(win_geom['x'], win_geom['y'], win_geom['width'], win_geom['height'])
            splitters = layout.get('splitters')
            if splitters:
                if 'main_splitter' in splitters and len(splitters['main_splitter']) == 2:
                    self.main_splitter.setSizes(splitters['main_splitter'])
                if 'right_splitter' in splitters and len(splitters['right_splitter']) == 2:
                    self.right_splitter.setSizes(splitters['right_splitter'])
        self.editor_panel.setVisible(self.editor_visible)
        self.editor_toggle_action.setChecked(not self.editor_visible)
        self.editor_toggle_action.setText("エディタ表示" if not self.editor_visible else "エディタ非表示")
        self.preview_panel.setVisible(self.preview_visible)
        self.preview_toggle_action.setChecked(not self.preview_visible)
        self.preview_toggle_action.setText("プレビュー表示" if not self.preview_visible else "プレビュー非表示")
    def closeEvent(self, event):
        if self.move_notes_action.isChecked():
            QMessageBox.warning(self, "確認", "まずメモの移動モードを終了してください。")
            event.ignore()
            return
        if self.controller.current_note_id and self.controller.current_note_id < 0:
            reply = QMessageBox.question(self, "未保存の新規メモ",
                                         "新規メモが保存されていません。保存しますか?",
                                         QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel)
            if reply == QMessageBox.StandardButton.Save:
                if not self.controller.save_note_and_update():
                    event.ignore()
                    return
            elif reply == QMessageBox.StandardButton.Discard:
                self.controller._clear_current_note_view()
            elif reply == QMessageBox.StandardButton.Cancel:
                event.ignore()
                return
        elif self.editor_panel.is_modified():
            reply = QMessageBox.question(self, "未保存の変更", "変更を保存しますか?",
                                         QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel)
            if reply == QMessageBox.StandardButton.Save:
                if not self.controller.save_note_and_update():
                    event.ignore()
                    return
            elif reply == QMessageBox.StandardButton.Cancel:
                event.ignore()
                return
        self.controller.cleanup_lock_files()
        
        is_maximized = self.isMaximized()
        if is_maximized:
            main_window_geom = {'is_maximized': True}
        else:
            geom = self.geometry()
            main_window_geom = {
                'is_maximized': False,
                'x': geom.x(), 'y': geom.y(),
                'width': geom.width(), 'height': geom.height()
            }
        
        right_splitter_sizes_to_save = self.right_splitter.sizes()
        if (not self.editor_visible or not self.preview_visible) and self.last_right_splitter_sizes:
            right_splitter_sizes_to_save = self.last_right_splitter_sizes
        
        layout_settings = {
            'main_window': main_window_geom,
            'splitters': {
                'main_splitter': self.main_splitter.sizes(),
                'right_splitter': right_splitter_sizes_to_save
            },
            'editor_visible': self.editor_visible,
            'preview_visible': self.preview_visible
        }
        self.settings_manager.set('window_layout', layout_settings)
        if self.controller.current_note_id: self.settings_manager.set('last_open_note_context', {"book": self.controller.current_book_name, "id": self.controller.current_note_id})
        else: self.settings_manager.set('last_open_note_context', None)
        if checked := self.view_action_group.checkedAction(): self.settings_manager.set('last_view_mode', checked.data())
        
        self.settings_manager.set('expanded_books', self.tree_panel.get_expanded_books())
        self.settings_manager.save()
        self.db_manager.close_all_connections()
        super().closeEvent(event)
# =================================================================
# 9. APPLICATION ENTRY POINT
# =================================================================
if __name__ == "__main__":
    app = QApplication(sys.argv)
    app.setStyle("Fusion")
    QCoreApplication.setOrganizationName("MyCompany"); QCoreApplication.setApplicationName(APP_NAME)
    # アプリケーションごとにユニークなキーを設定
    shared_memory_key = "akashic-index-single-instance-lock"
    shared_memory = QSharedMemory(shared_memory_key)
    # create(1) は、1バイトの共有メモリセグメントを作成しようと試みる
    # 成功すれば (True)、このインスタンスが最初のもの
    # 失敗すれば (False)、既に他のインスタンスが存在する
    if not shared_memory.create(1):
        QMessageBox.critical(None, APP_NAME, "アプリケーションは既に実行中です。")
        sys.exit(1) # アプリケーションを終了
    settings = SettingsManager()
    settings.apply_app_theme()
    
    db_manager = DatabaseManager(settings)
    md_service = MarkdownService(settings)
    window = MainWindow(db_manager, settings)
    
    controller = AppController(
        main_win=window, db_manager=db_manager, tree=window.tree_panel,
        editor=window.editor_panel, preview=window.preview_panel,
        md_service=md_service, settings=settings
    )
    window.controller = controller; window.tree_panel.controller = controller
    settings.parent_controller = controller
    def check_and_handle_existing_locks():
        """
        起動時に既存のロックファイルをチェックし、適切に処理する。
        自分自身のロックファイルは「セッションの引き継ぎ」とみなし、書き込み可能状態にする。
        """
        try:
            current_user = getpass.getuser()
        except Exception:
            current_user = "unknown"
        for book_name in db_manager.get_all_book_names():
            lock_file_path = controller.get_lock_file_path(book_name)
            if lock_file_path and os.path.exists(lock_file_path):
                try:
                    owner_user = ""
                    with open(lock_file_path, 'r') as f:
                        # 1行目を読んで 'user:' プレフィックスがあるか確認
                        line = f.readline().strip()
                        if line.startswith('user:'):
                            owner_user = line.split(':', 1)[1]
                    
                    # ロックファイルの所有者が現在のユーザーと一致する場合
                    if owner_user == current_user:
                        print(f"Previous session lock for '{book_name}' found. Re-acquiring lock.")
                        # アプリの状態を直接「書き込み可能」に設定する
                        controller.book_lock_states[book_name] = 'writable'
                except (IOError, IndexError, OSError) as e:
                    print(f"Could not process lock file {lock_file_path}: {e}")
#    check_stale_lock_files()
    check_and_handle_existing_locks() 
    window.editor_panel.set_font(settings.get_editor_font())
    window.tree_panel.update_widget_fonts()
    window.preview_panel.set_address_bar_visibility(settings.get('show_address_bar', False))
    window.show()
    def restore_session_state():
        window.update_view_toolbar()
        controller.update_home_button_view()
        last_context = settings.get('last_open_note_context')
        if last_context and db_manager.get_note_model(last_context["book"]):
            controller.on_note_selected(last_context["book"], last_context["id"])
            QTimer.singleShot(50, lambda: window.tree_panel.select_item_by_id(last_context["book"], last_context["id"]))
        # --- ▼▼▼【修正】ここからが、ローカルDBを自動ロックするための復活したロジックです ▼▼▼ ---
        # 読み込まれているすべてのブックに対してループ処理
        for book_name, model in db_manager.connections.items():
            # 1. ブックが現在ロックされていない(locked_out)か確認
            if controller.book_lock_states.get(book_name) == 'locked_out':
                # 2. そのブックのDBパスがクラウドストレージではない(ローカルである)ことを確認
                if not controller._is_cloud_storage_path(model.db_path):
                    print(f"Attempting to auto-unlock local book: '{book_name}'")
                    # 3. 条件に合致すれば、ロック取得処理を開始する
                    #    (start_lockoutはローカルDBの場合、待機時間なしで即時完了します)
                    controller.start_lockout(book_name)
        # --- ▲▲▲ 修正ここまで ▲▲▲ ---
        # 最後にUIの状態を更新して、セッション復元を完了する
        QTimer.singleShot(10, window.tree_panel.restore_expanded_state)
        QTimer.singleShot(10, controller.update_ui_for_lock_state)
    # 遅延を500ミリ秒に増やし、システムの初期化が完了するのを待つ
    QTimer.singleShot(500, restore_session_state)
    sys.exit(app.exec())
  • コメントを投稿するためにはログインして下さい。

Produceby HASHIBAMI