Spaces:
Sleeping
Sleeping
| # -*- 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): | |
| def decorated(*args, **kwargs): | |
| # デモ版:認証チェックなし、常に通す | |
| session["authenticated"] = True | |
| return f(*args, **kwargs) | |
| return decorated | |
| # ============================================================ | |
| # ルート定義 | |
| # ============================================================ | |
| def login(): | |
| """デモ版:認証スキップ、直接ダッシュボードへ""" | |
| session["authenticated"] = True | |
| load_note_data() | |
| return redirect(url_for("dashboard")) | |
| def logout(): | |
| """ログアウト(メモリデータも削除)""" | |
| sid = session.get("sid") | |
| if sid and sid in _memory_store: | |
| del _memory_store[sid] | |
| session.clear() | |
| return redirect(url_for("login")) | |
| 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 | |
| ) | |
| 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 | |
| ) | |
| 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 | |
| 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 | |
| ) | |
| 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")) | |
| 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")) | |
| 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 | |
| ) | |
| 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) | |