diff --git a/glance-system/glance-helper.yaml b/glance-system/glance-helper.yaml
index 9b071d2..b716314 100644
--- a/glance-system/glance-helper.yaml
+++ b/glance-system/glance-helper.yaml
@@ -173,7 +173,7 @@ data:
}});
if (response.ok) {{
lastSaved = content;
- updateStatus('Saved ✓', 'saved');
+ updateStatus('Saved ✓', 'saved');
setTimeout(() => updateStatus(''), 2000);
}} else {{
updateStatus('Save failed', 'error');
@@ -209,6 +209,491 @@ data:
return {"status": "ok", "user": safe_user}
return Response(content="Failed to save", status_code=500)
+ # ================================
+ # Unified User Data System (Notes, Todo, Motivation)
+ # ================================
+ # File format:
+ # [Notes]
+ # Free-form notes text...
+ #
+ # [Todo]
+ # - [ ] Uncompleted task
+ # - [x] Completed task
+ #
+ # [Motivation]
+ # Quote 1
+ # Quote 2
+
+ def get_userdata_file(user: str) -> str:
+ """Get user data file path, with validation."""
+ safe_user = re.sub(r'[^a-zA-Z0-9_-]', '', user) or "default"
+ data_dir = os.environ.get("DATA_DIR", "/data")
+ return os.path.join(data_dir, f"userdata_{safe_user}.txt")
+
+ def load_userdata(user: str) -> dict:
+ """Load and parse user data file into sections."""
+ filepath = get_userdata_file(user)
+ sections = {"Notes": "", "Todo": [], "Motivation": []}
+ try:
+ if os.path.exists(filepath):
+ with open(filepath, "r", encoding="utf-8") as f:
+ content = f.read()
+ current_section = None
+ section_lines = []
+ for line in content.split('\n'):
+ stripped = line.strip()
+ if stripped.startswith('[') and stripped.endswith(']'):
+ # Save previous section
+ if current_section == "Notes":
+ sections["Notes"] = '\n'.join(section_lines).strip()
+ elif current_section == "Todo":
+ for l in section_lines:
+ l = l.strip()
+ if l.startswith('- [x] '):
+ sections["Todo"].append({"text": l[6:], "done": True})
+ elif l.startswith('- [ ] '):
+ sections["Todo"].append({"text": l[6:], "done": False})
+ elif current_section == "Motivation":
+ for l in section_lines:
+ l = l.strip()
+ if l:
+ sections["Motivation"].append(l)
+ # Start new section
+ current_section = stripped[1:-1]
+ section_lines = []
+ else:
+ section_lines.append(line)
+ # Handle last section
+ if current_section == "Notes":
+ sections["Notes"] = '\n'.join(section_lines).strip()
+ elif current_section == "Todo":
+ for l in section_lines:
+ l = l.strip()
+ if l.startswith('- [x] '):
+ sections["Todo"].append({"text": l[6:], "done": True})
+ elif l.startswith('- [ ] '):
+ sections["Todo"].append({"text": l[6:], "done": False})
+ elif current_section == "Motivation":
+ for l in section_lines:
+ l = l.strip()
+ if l:
+ sections["Motivation"].append(l)
+ except Exception as e:
+ print(f"Error loading userdata for {user}: {e}")
+ return sections
+
+ def save_userdata(user: str, sections: dict) -> bool:
+ """Save sections back to user data file."""
+ filepath = get_userdata_file(user)
+ try:
+ lines = []
+ lines.append("[Notes]")
+ lines.append(sections.get("Notes", ""))
+ lines.append("")
+ lines.append("[Todo]")
+ for item in sections.get("Todo", []):
+ mark = "x" if item.get("done") else " "
+ lines.append(f"- [{mark}] {item.get('text', '')}")
+ lines.append("")
+ lines.append("[Motivation]")
+ for quote in sections.get("Motivation", []):
+ lines.append(quote)
+ with open(filepath, "w", encoding="utf-8") as f:
+ f.write('\n'.join(lines))
+ return True
+ except Exception as e:
+ print(f"Error saving userdata for {user}: {e}")
+ return False
+
+ # --------------------------------
+ # Todo Widget
+ # --------------------------------
+ @APP.get("/userdata/todo")
+ def todo_widget(key: str = "", user: str = "default", accent: str = "4ade80", bgcolor: str = "0d1117"):
+ """Serve the Todo widget HTML page."""
+ expected_key = os.environ.get("GLANCE_HELPER_KEY", "")
+ if key != expected_key:
+ return Response(content="Unauthorized", status_code=401)
+
+ safe_user = re.sub(r'[^a-zA-Z0-9_-]', '', user) or "default"
+ safe_accent = re.sub(r'[^a-fA-F0-9]', '', accent)[:6] or "4ade80"
+ safe_bgcolor = re.sub(r'[^a-fA-F0-9]', '', bgcolor)[:6] or "0d1117"
+
+ sections = load_userdata(safe_user)
+ todos = sections.get("Todo", [])
+
+ # Build todo items HTML
+ todo_html = ""
+ for i, item in enumerate(todos):
+ checked = "checked" if item.get("done") else ""
+ text_escaped = item.get("text", "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
+ done_class = "done" if item.get("done") else ""
+ todo_html += f'''
+
+ {text_escaped}
+
+
'''
+
+ html = f"""
+
+
+
+
+
+
+
+
+
+
+
+
+
{todo_html if todo_html else '
No tasks yet
'}
+
+
+
+
+"""
+ return Response(content=html, media_type="text/html")
+
+ @APP.post("/userdata/todo/add")
+ async def todo_add(key: str = "", user: str = "default", content: dict = None):
+ expected_key = os.environ.get("GLANCE_HELPER_KEY", "")
+ if key != expected_key:
+ return Response(content="Unauthorized", status_code=401)
+ safe_user = re.sub(r'[^a-zA-Z0-9_-]', '', user) or "default"
+ if content and content.get("text"):
+ sections = load_userdata(safe_user)
+ sections["Todo"].append({"text": content["text"].strip(), "done": False})
+ if save_userdata(safe_user, sections):
+ return {"status": "ok"}
+ return Response(content="Failed", status_code=500)
+
+ @APP.post("/userdata/todo/toggle")
+ async def todo_toggle(key: str = "", user: str = "default", index: int = 0):
+ expected_key = os.environ.get("GLANCE_HELPER_KEY", "")
+ if key != expected_key:
+ return Response(content="Unauthorized", status_code=401)
+ safe_user = re.sub(r'[^a-zA-Z0-9_-]', '', user) or "default"
+ sections = load_userdata(safe_user)
+ if 0 <= index < len(sections["Todo"]):
+ sections["Todo"][index]["done"] = not sections["Todo"][index]["done"]
+ if save_userdata(safe_user, sections):
+ return {"status": "ok"}
+ return Response(content="Failed", status_code=500)
+
+ @APP.post("/userdata/todo/delete")
+ async def todo_delete(key: str = "", user: str = "default", index: int = 0):
+ expected_key = os.environ.get("GLANCE_HELPER_KEY", "")
+ if key != expected_key:
+ return Response(content="Unauthorized", status_code=401)
+ safe_user = re.sub(r'[^a-zA-Z0-9_-]', '', user) or "default"
+ sections = load_userdata(safe_user)
+ if 0 <= index < len(sections["Todo"]):
+ sections["Todo"].pop(index)
+ if save_userdata(safe_user, sections):
+ return {"status": "ok"}
+ return Response(content="Failed", status_code=500)
+
+ # --------------------------------
+ # Motivation Widget
+ # --------------------------------
+ import random as _random
+
+ @APP.get("/userdata/motivation")
+ def motivation_widget(key: str = "", user: str = "default", accent: str = "4ade80", bgcolor: str = "0d1117"):
+ """Serve the Motivation widget HTML page with random quote and settings modal."""
+ expected_key = os.environ.get("GLANCE_HELPER_KEY", "")
+ if key != expected_key:
+ return Response(content="Unauthorized", status_code=401)
+
+ safe_user = re.sub(r'[^a-zA-Z0-9_-]', '', user) or "default"
+ safe_accent = re.sub(r'[^a-fA-F0-9]', '', accent)[:6] or "4ade80"
+ safe_bgcolor = re.sub(r'[^a-fA-F0-9]', '', bgcolor)[:6] or "0d1117"
+
+ sections = load_userdata(safe_user)
+ quotes = sections.get("Motivation", [])
+
+ # Pick random quote
+ current_quote = _random.choice(quotes) if quotes else "Add your first motivational quote!"
+ current_quote_escaped = current_quote.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
+
+ # Build quotes list for modal
+ quotes_html = ""
+ for i, q in enumerate(quotes):
+ q_escaped = q.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
+ quotes_html += f'{q_escaped}
'
+
+ html = f"""
+
+
+
+
+
+
+
+
+
+
+
"{current_quote_escaped}"
+
+
{len(quotes)} quote{'s' if len(quotes) != 1 else ''}
+
+
+
+
+
+
+ {quotes_html if quotes_html else '
No quotes yet. Add your first one below!
'}
+
+
+
+
+
+
+
+"""
+ return Response(content=html, media_type="text/html")
+
+ @APP.post("/userdata/motivation/add")
+ async def motivation_add(key: str = "", user: str = "default", content: dict = None):
+ expected_key = os.environ.get("GLANCE_HELPER_KEY", "")
+ if key != expected_key:
+ return Response(content="Unauthorized", status_code=401)
+ safe_user = re.sub(r'[^a-zA-Z0-9_-]', '', user) or "default"
+ if content and content.get("text"):
+ sections = load_userdata(safe_user)
+ sections["Motivation"].append(content["text"].strip())
+ if save_userdata(safe_user, sections):
+ return {"status": "ok"}
+ return Response(content="Failed", status_code=500)
+
+ @APP.post("/userdata/motivation/delete")
+ async def motivation_delete(key: str = "", user: str = "default", index: int = 0):
+ expected_key = os.environ.get("GLANCE_HELPER_KEY", "")
+ if key != expected_key:
+ return Response(content="Unauthorized", status_code=401)
+ safe_user = re.sub(r'[^a-zA-Z0-9_-]', '', user) or "default"
+ sections = load_userdata(safe_user)
+ if 0 <= index < len(sections["Motivation"]):
+ sections["Motivation"].pop(index)
+ if save_userdata(safe_user, sections):
+ return {"status": "ok"}
+ return Response(content="Failed", status_code=500)
+
+ @APP.get("/userdata/motivation/random")
+ def motivation_random(key: str = "", user: str = "default"):
+ """Get a random motivation quote as JSON."""
+ expected_key = os.environ.get("GLANCE_HELPER_KEY", "")
+ if key != expected_key:
+ return Response(content="Unauthorized", status_code=401)
+ safe_user = re.sub(r'[^a-zA-Z0-9_-]', '', user) or "default"
+ sections = load_userdata(safe_user)
+ quotes = sections.get("Motivation", [])
+ if quotes:
+ return {"quote": _random.choice(quotes), "total": len(quotes)}
+ return {"quote": "", "total": 0}
+
# ================================
# Időkép configuration
# ================================
diff --git a/glance-system/glance-kisfenyo.yaml b/glance-system/glance-kisfenyo.yaml
index 8103b47..b9175ed 100644
--- a/glance-system/glance-kisfenyo.yaml
+++ b/glance-system/glance-kisfenyo.yaml
@@ -763,9 +763,18 @@ data:
# Calendar Widget
- type: calendar
first-day-of-week: monday
- # To-Do List
- - type: to-do
+ # Tasks (persistent, file-based)
+ - type: iframe
+ css-class: iframe-no-tint
+ source: https://glance-helper.dooplex.hu/userdata/todo?key=oplQqnLnJK2vErRVYJpvVUcSDBOSbCHZSbsYY2bwSifgTMfT&user=kisfenyo&accent=4ade80&bgcolor=0d1117
+ height: 200
title: Tasks
+ # Motivation Quote
+ - type: iframe
+ css-class: iframe-no-tint
+ source: https://glance-helper.dooplex.hu/userdata/motivation?key=oplQqnLnJK2vErRVYJpvVUcSDBOSbCHZSbsYY2bwSifgTMfT&user=kisfenyo&accent=4ade80&bgcolor=0d1117
+ height: 100
+ title: Motivation
# Outline Notes iframe
- type: iframe
source: https://outline.dooplex.hu/collection/dooplex-server-iTAZn04AaR/recent