# -*- coding: utf-8 -*- """ エンディングノート - デモ版 (Hugging Face Spaces用) ・パスワード固定: demo ・データはメモリのみ保存(再起動でリセット) ・ファイルへの書き込みなし """ import os import json import io import zipfile import urllib.request import tempfile from datetime import datetime, date from functools import wraps from copy import deepcopy from flask import ( Flask, render_template, request, redirect, url_for, session, jsonify, send_file, flash ) from dotenv import load_dotenv load_dotenv() # ============================================================ # アプリ初期化 # ============================================================ app = Flask(__name__) app.secret_key = os.environ.get("SECRET_KEY", "ending-note-demo-secret-2024") # デモ用固定パスワード DEMO_PASSWORD = "demo" # ============================================================ # パス定数(PDF出力用のみ) # ============================================================ BASE_DIR = os.path.dirname(__file__) FONTS_DIR = os.path.join(BASE_DIR, "static", "fonts") # HF Spaces(Linux)ではシステムフォントを優先使用 LINUX_FONT_CANDIDATES = [ "/usr/share/fonts/opentype/ipafont-mincho/ipam.ttf", "/usr/share/fonts/truetype/fonts-ipafont-mincho/ipam.ttf", "/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf", "/usr/share/fonts/truetype/vlgothic/VL-Gothic-Regular.ttf", ] FONT_PATH = os.path.join(FONTS_DIR, "ipaexm.ttf") # Linuxシステムフォントが存在すれば優先使用 for _lf in LINUX_FONT_CANDIDATES: if os.path.exists(_lf): FONT_PATH = _lf break # ============================================================ # メモリ内データストア # セッションIDをキーにノートデータを保持する # ============================================================ _memory_store = {} def _get_session_id(): """セッションIDを取得または生成する""" if "sid" not in session: import uuid session["sid"] = str(uuid.uuid4()) return session["sid"] def load_note_data(): """メモリからノートデータを取得する。なければ初期データを返す。""" sid = _get_session_id() if sid not in _memory_store: _memory_store[sid] = _default_note_data() return deepcopy(_memory_store[sid]) def save_note_data(data): """メモリにノートデータを保存する(ファイルには書かない)""" data.setdefault("meta", {}) data["meta"]["updated_at"] = date.today().isoformat() if not data["meta"].get("created_at"): data["meta"]["created_at"] = date.today().isoformat() sid = _get_session_id() _memory_store[sid] = deepcopy(data) def _default_note_data(): """デフォルトのnote_data構造を返す""" return { "meta": { "created_at": date.today().isoformat(), "updated_at": date.today().isoformat() }, "sections": { "basic": { "name_kanji": "", "name_kana": "", "birthdate": "", "birthplace": "", "honseki": "", "address": "", "blood_type": "", "my_number_location": "" }, "family": {"members": [], "emergency": [], "friends": []}, "assets": { "deposits": [], "stocks": [], "real_estate": [], "insurance": [], "pension_type": "", "pension_account": "", "debts": [], "account_location": "" }, "digital": { "device_lock_location": "", "accounts": [], "subscriptions": [] }, "medical": { "chronic_diseases": "", "allergies": "", "medications": [], "doctors": [], "care_preference": "", "care_preference_note": "", "care_person": "", "care_funds": "", "life_sustaining": "", "diagnosis_disclosure": "", "organ_donation": "", "organ_donation_note": "", "living_will": False, "living_will_location": "" }, "funeral": { "style": "", "budget": "", "prepaid": False, "prepaid_detail": "", "coffin_items": "", "photo_location": "", "attendees": [], "grave_type": "", "grave_note": "", "religion": "" }, "will": {"existence": "", "format": "", "location_or_contact": "", "notes": ""}, "will_template": { "testator_name": "", "testator_birthdate": "", "testator_address": "", "items": [], "message": "", "date": "" }, "life_story": { "childhood": "", "career": "", "hobbies": "", "family_message": "", "messages": [], "pets": [] } } } # ============================================================ # フォント(PDF用) # ============================================================ def ensure_directories(): os.makedirs(FONTS_DIR, exist_ok=True) def ensure_font(): if os.path.exists(FONT_PATH): return print("[INFO] IPAexMinchoフォントをダウンロード中...") font_url = "https://moji.or.jp/wp-content/ipafont/IPAexfont/ipaexm.zip" try: with tempfile.TemporaryDirectory() as tmp_dir: zip_path = os.path.join(tmp_dir, "ipaexm.zip") urllib.request.urlretrieve(font_url, zip_path) with zipfile.ZipFile(zip_path, "r") as zf: for name in zf.namelist(): if name.endswith("ipaexm.ttf"): zf.extract(name, tmp_dir) import shutil shutil.copy2(os.path.join(tmp_dir, name), FONT_PATH) print(f"[INFO] フォント配置完了: {FONT_PATH}") return except Exception as e: print(f"[WARNING] フォントダウンロード失敗: {e}") # ============================================================ # 完成度計算 # ============================================================ SECTION_IMPORTANT_FIELDS = { "basic": ["name_kanji", "birthdate", "address", "blood_type"], "family": ["members", "emergency"], "assets": ["deposits", "account_location"], "digital": ["device_lock_location", "accounts"], "medical": ["chronic_diseases", "medications", "doctors", "life_sustaining", "organ_donation"], "funeral": ["style", "grave_type"], "will": ["existence", "location_or_contact"], "will_template": ["testator_name", "items", "date"], "life_story": ["childhood", "family_message"] } def calc_section_progress(section_id, section_data): fields = SECTION_IMPORTANT_FIELDS.get(section_id, []) if not fields: return 0 filled = 0 for f in fields: val = section_data.get(f) if isinstance(val, list): if len(val) > 0: filled += 1 elif isinstance(val, bool): filled += 1 elif val: filled += 1 return int(filled / len(fields) * 100) def calc_all_progress(note_data): result = {} sections = note_data.get("sections", {}) for sid, sdata in sections.items(): result[sid] = calc_section_progress(sid, sdata) return result # ============================================================ # セクション定義 # ============================================================ SECTIONS = [ {"id": "basic", "label": "基本情報", "icon": "👤"}, {"id": "family", "label": "家族構成・緊急連絡先", "icon": "👨‍👩‍👧"}, {"id": "assets", "label": "財産・負債", "icon": "💴"}, {"id": "digital", "label": "デジタル遺産", "icon": "💻"}, {"id": "medical", "label": "医療・介護の希望", "icon": "🏥"}, {"id": "funeral", "label": "葬儀・供養の意向", "icon": "🌸"}, {"id": "will", "label": "遺言書・法的情報", "icon": "📜"}, {"id": "will_template","label": "遺言状テンプレート", "icon": "✍️"}, {"id": "life_story", "label": "自分史・メッセージ", "icon": "📖"}, ] # ============================================================ # 認証デコレータ # ============================================================ def login_required(f): @wraps(f) def decorated(*args, **kwargs): # デモ版:認証チェックなし、常に通す session["authenticated"] = True return f(*args, **kwargs) return decorated # ============================================================ # ルート定義 # ============================================================ @app.route("/login", methods=["GET", "POST"]) def login(): """デモ版:認証スキップ、直接ダッシュボードへ""" session["authenticated"] = True load_note_data() return redirect(url_for("dashboard")) @app.route("/logout") def logout(): """ログアウト(メモリデータも削除)""" sid = session.get("sid") if sid and sid in _memory_store: del _memory_store[sid] session.clear() return redirect(url_for("login")) @app.route("/") @login_required def dashboard(): """ダッシュボード""" note_data = load_note_data() progress = calc_all_progress(note_data) total_progress = int(sum(progress.values()) / len(progress)) if progress else 0 updated_at = note_data.get("meta", {}).get("updated_at", "") show_reminder = False if updated_at: try: diff = (date.today() - date.fromisoformat(updated_at)).days show_reminder = diff >= 365 except Exception: pass overall_pct = total_progress return render_template( "dashboard.html", sections=SECTIONS, progress=progress, total_progress=total_progress, overall_pct=overall_pct, note_data=note_data, show_reminder=show_reminder, is_demo=True ) @app.route("/section/", methods=["GET"]) @login_required def section_view(section_id): """セクション編集画面""" valid_ids = [s["id"] for s in SECTIONS] if section_id not in valid_ids: return redirect(url_for("dashboard")) note_data = load_note_data() section_data = note_data.get("sections", {}).get(section_id, {}) section_info = next(s for s in SECTIONS if s["id"] == section_id) idx = valid_ids.index(section_id) prev_section = SECTIONS[idx - 1] if idx > 0 else None next_section = SECTIONS[idx + 1] if idx < len(SECTIONS) - 1 else None return render_template( "section.html", section_id=section_id, section_info=section_info, section_data=section_data, sections=SECTIONS, prev_section=prev_section, next_section=next_section, section_data_json=json.dumps(section_data, ensure_ascii=False), is_demo=True ) @app.route("/api/save", methods=["POST"]) @login_required def api_save(): """ 自動保存API(メモリのみ保存・ファイルには書かない) """ try: payload = request.get_json() if not payload: return jsonify({"status": "error", "message": "データなし"}), 400 section_id = payload.get("section_id") new_data = payload.get("data", {}) valid_ids = [s["id"] for s in SECTIONS] if section_id not in valid_ids: return jsonify({"status": "error", "message": "不正なセクションID"}), 400 note_data = load_note_data() note_data["sections"][section_id] = new_data save_note_data(note_data) # メモリのみ return jsonify({"status": "ok", "updated_at": note_data["meta"]["updated_at"]}) except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500 @app.route("/export/json") @login_required def export_json(): """JSONエクスポート(メモリデータから生成)""" note_data = load_note_data() today = date.today().strftime("%Y%m%d") filename = f"ending_note_backup_{today}.json" buf = io.BytesIO() buf.write(json.dumps(note_data, ensure_ascii=False, indent=2).encode("utf-8")) buf.seek(0) return send_file( buf, mimetype="application/json", download_name=filename, as_attachment=True ) @app.route("/import/json", methods=["POST"]) @login_required def import_json(): """JSONインポート(メモリに復元)""" try: f = request.files.get("file") if not f: flash("ファイルが選択されていません。", "error") return redirect(url_for("dashboard")) raw = f.read().decode("utf-8") imported = json.loads(raw) if "sections" not in imported: flash("インポートファイルの形式が正しくありません。", "error") return redirect(url_for("dashboard")) save_note_data(imported) # メモリのみ flash("データをインポートしました。(デモ版のため再起動でリセットされます)", "success") return redirect(url_for("dashboard")) except Exception as e: flash(f"インポートに失敗しました: {e}", "error") return redirect(url_for("dashboard")) @app.route("/export/pdf") @login_required def export_pdf(): """PDFエクスポート(メモリバッファ方式)""" note_data = load_note_data() today_str = date.today().strftime("%Y%m%d") filename = f"ending_note_{today_str}.pdf" try: buf = io.BytesIO() _generate_pdf_to_buffer(note_data, buf) buf.seek(0) return send_file(buf, mimetype="application/pdf", download_name=filename, as_attachment=True) except Exception as e: flash(f"PDF出力に失敗しました: {e}", "error") return redirect(url_for("dashboard")) @app.route("/print-preview") @login_required def print_preview(): """印刷プレビュー画面""" note_data = load_note_data() return render_template( "print_preview.html", note_data=note_data, sections=SECTIONS, today=date.today().isoformat(), is_demo=True ) @app.route("/will-preview") @login_required def will_preview(): """遺言状HTMLプレビュー""" note_data = load_note_data() will_tpl = note_data.get("sections", {}).get("will_template", {}) return render_template("will_preview.html", data=will_tpl, today=date.today().isoformat()) # ============================================================ # PDF生成ロジック(元のapp.pyと同じ) # ============================================================ def _generate_pdf_to_buffer(note_data, buf): """PDFをメモリバッファに生成する""" _generate_pdf(note_data, buf) def _generate_pdf(note_data, filepath): from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import mm from reportlab.lib import colors from reportlab.platypus import ( SimpleDocTemplate, Paragraph, Spacer, HRFlowable ) from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont if os.path.exists(FONT_PATH): try: pdfmetrics.registerFont(TTFont("IPAexMincho", FONT_PATH)) font_name = "IPAexMincho" except Exception as e: print(f"[WARNING] IPAexMincho登録失敗: {e}") font_name = "Helvetica" if font_name == "Helvetica": WINDOWS_FONTS = [ ("MSMincho", "C:/Windows/Fonts/msmincho.ttc"), ("MSGothic", "C:/Windows/Fonts/msgothic.ttc"), ("YuMincho", "C:/Windows/Fonts/yumin.ttf"), ("Meiryo", "C:/Windows/Fonts/meiryo.ttc"), ] for fname, fpath in WINDOWS_FONTS: if os.path.exists(fpath): try: pdfmetrics.registerFont(TTFont(fname, fpath)) font_name = fname break except Exception: continue doc = SimpleDocTemplate( filepath, pagesize=A4, topMargin=20*mm, bottomMargin=20*mm, leftMargin=20*mm, rightMargin=20*mm, ) dark_green = colors.Color(0.176, 0.314, 0.086) light_gray = colors.Color(0.91, 0.894, 0.863) title_style = ParagraphStyle("Title", fontName=font_name, fontSize=18, spaceAfter=4, textColor=dark_green, alignment=1) sub_style = ParagraphStyle("Sub", fontName=font_name, fontSize=10, spaceAfter=2, textColor=colors.grey, alignment=1) heading_style = ParagraphStyle("Heading", fontName=font_name, fontSize=13, spaceBefore=8, spaceAfter=4, textColor=dark_green) body_style = ParagraphStyle("Body", fontName=font_name, fontSize=10, spaceAfter=3, leading=16) label_style = ParagraphStyle("Label", fontName=font_name, fontSize=9, spaceAfter=1, textColor=colors.grey) note_style = ParagraphStyle("Note", fontName=font_name, fontSize=8, spaceAfter=2, textColor=colors.red) def val(text): return text if text else "(未記入)" def field(label_text, value_text): return [ Paragraph(label_text, label_style), Paragraph(val(value_text), body_style), Spacer(1, 3), ] def section_heading(title_text): return [ Spacer(1, 8), HRFlowable(width="100%", thickness=1.5, color=dark_green), Paragraph(title_text, heading_style), HRFlowable(width="100%", thickness=0.5, color=light_gray), Spacer(1, 4), ] story = [] meta = note_data.get("meta", {}) secs = note_data.get("sections", {}) basic = secs.get("basic", {}) story += [ Spacer(1, 10*mm), Paragraph("私のエンディングノート(デモ版)", title_style), Spacer(1, 5), Paragraph(f"作成日:{val(meta.get('created_at'))}", sub_style), Paragraph(f"氏 名:{val(basic.get('name_kanji'))}", sub_style), Spacer(1, 10*mm), HRFlowable(width="100%", thickness=2, color=dark_green), Spacer(1, 8*mm), ] story += section_heading("基本情報") story += field("氏名(漢字)", basic.get("name_kanji")) story += field("氏名(ふりがな)", basic.get("name_kana")) story += field("生年月日", basic.get("birthdate")) story += field("出生地", basic.get("birthplace")) story += field("本籍地", basic.get("honseki")) story += field("現住所", basic.get("address")) story += field("血液型", basic.get("blood_type")) family = secs.get("family", {}) story += section_heading("家族構成・緊急連絡先") for m in family.get("members", []): story.append(Paragraph(f"{val(m.get('relation'))}:{val(m.get('name'))}", body_style)) for e in family.get("emergency", []): story.append(Paragraph(f"{val(e.get('name'))} Tel: {val(e.get('phone'))}", body_style)) assets = secs.get("assets", {}) story += section_heading("財産・負債") for d in assets.get("deposits", []): story.append(Paragraph(f"{val(d.get('bank'))} {val(d.get('branch'))}", body_style)) for dbt in assets.get("debts", []): story.append(Paragraph(f"{val(dbt.get('type'))}:{val(dbt.get('creditor'))}", body_style)) medical = secs.get("medical", {}) story += section_heading("医療・介護の希望") story += field("延命治療", medical.get("life_sustaining")) story += field("臓器提供", medical.get("organ_donation")) funeral = secs.get("funeral", {}) story += section_heading("葬儀・供養の意向") story += field("葬儀形式", funeral.get("style")) story += field("お墓・納骨の希望", funeral.get("grave_type")) ls = secs.get("life_story", {}) story += section_heading("自分史・メッセージ") story += field("家族へのメッセージ", ls.get("family_message")) doc.build(story) # ============================================================ # アプリ起動(HF Spaces: port 7860) # ============================================================ if __name__ == "__main__": ensure_directories() ensure_font() app.run(host="0.0.0.0", port=7860, debug=False)