From 2f0009df56eb67281c79954bdd14ca641c25c473 Mon Sep 17 00:00:00 2001 From: Zaine Date: Fri, 10 Jul 2026 10:28:47 +0100 Subject: [PATCH] adding more things --- src/authoring_service/hidden.py | 167 +++++++--- src/authoring_service/templates.py | 471 ++++++++++++++++++++++++----- src/tests/test_authoring_server.py | 88 +++++- 3 files changed, 599 insertions(+), 127 deletions(-) diff --git a/src/authoring_service/hidden.py b/src/authoring_service/hidden.py index f305313..68d2916 100755 --- a/src/authoring_service/hidden.py +++ b/src/authoring_service/hidden.py @@ -335,7 +335,7 @@ def normalize_hidden_entry(raw: dict[str, Any], existing: dict[str, Any] | None return entry -def normalize_hidden_story(raw: dict[str, Any], entries: list[dict[str, Any]] | None = None, existing: dict[str, Any] | None = None) -> dict[str, Any]: +def normalize_hidden_story(raw: dict[str, Any], entries: list[dict[str, Any]] | None = None, existing: dict[str, Any] | None = None) -> dict[str, Any]: today = datetime.now().date().isoformat() story = existing.copy() if existing else {} story.update(raw) @@ -407,10 +407,81 @@ def normalize_hidden_story(raw: dict[str, Any], entries: list[dict[str, Any]] | "markers": markers, "createdDate": str(story.get("createdDate") or today), "modifiedDate": today, - } - - -def migrate_hidden_stories(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: + } + + +def normalize_life_arc(raw: dict[str, Any], entries: list[dict[str, Any]] | None = None, stories: list[dict[str, Any]] | None = None, existing: dict[str, Any] | None = None) -> dict[str, Any]: + today = datetime.now().date().isoformat() + arc = existing.copy() if existing else {} + arc.update(raw) + title = str(normalize_character_text_refs(arc.get("title") or "")).strip() + if not title: + raise ValueError("Every life arc needs a title.") + arc_id = slugify(str(arc.get("id") or f"arc-{title}")) + if not arc_id.startswith("arc-"): + arc_id = f"arc-{arc_id}" + entry_ids = {entry["id"] for entry in entries or []} + story_ids = {story["id"] for story in stories or []} + chapters = [] + for index, raw_chapter in enumerate(arc.get("chapters", [])): + if not isinstance(raw_chapter, dict): + continue + chapter_title = str(normalize_character_text_refs(raw_chapter.get("title") or f"Chapter {index + 1}")).strip() + chapter_id = slugify(str(raw_chapter.get("id") or f"{arc_id}-chapter-{index + 1}-{chapter_title}")) + if not chapter_id.startswith("chapter-"): + chapter_id = f"chapter-{chapter_id}" + moments = [] + for moment_index, raw_moment in enumerate(raw_chapter.get("moments", [])): + if not isinstance(raw_moment, dict): + raw_moment = {"text": str(raw_moment)} + text = str(normalize_character_text_refs(raw_moment.get("text") or raw_moment.get("content") or "")).strip() + lesson = str(normalize_character_text_refs(raw_moment.get("lesson") or "")).strip() + if not text and not lesson: + continue + moment_id = slugify(str(raw_moment.get("id") or f"{chapter_id}-moment-{moment_index + 1}")) + if not moment_id.startswith("moment-"): + moment_id = f"moment-{moment_id}" + linked_entry_id = str(raw_moment.get("linkedEntryId") or "").strip() + linked_story_id = str(raw_moment.get("linkedStoryId") or "").strip() + moments.append({ + "id": moment_id, + "date": str(raw_moment.get("date") or ""), + "kind": str(raw_moment.get("kind") or "moment"), + "text": text, + "feeling": str(normalize_character_text_refs(raw_moment.get("feeling") or "")).strip(), + "lesson": lesson, + "metric": str(raw_moment.get("metric") or ""), + "sourcePointer": str(raw_moment.get("sourcePointer") or ""), + "visibility": str(raw_moment.get("visibility") or "private"), + "linkedEntryId": linked_entry_id if linked_entry_id in entry_ids else "", + "linkedStoryId": linked_story_id if linked_story_id in story_ids else "", + }) + chapters.append({ + "id": chapter_id, + "title": chapter_title, + "timeframe": str(normalize_character_text_refs(raw_chapter.get("timeframe") or "")).strip(), + "tone": str(raw_chapter.get("tone") or arc.get("tone") or "warm"), + "purpose": str(normalize_character_text_refs(raw_chapter.get("purpose") or "")).strip(), + "storyDraft": str(normalize_character_text_refs(raw_chapter.get("storyDraft") or "")).replace("\r\n", "\n"), + "linkedStoryId": str(raw_chapter.get("linkedStoryId") or "") if str(raw_chapter.get("linkedStoryId") or "") in story_ids else "", + "moments": moments, + }) + return { + "id": arc_id, + "title": title, + "domain": str(normalize_character_text_refs(arc.get("domain") or "")).strip(), + "summary": str(normalize_character_text_refs(arc.get("summary") or "")).strip(), + "tone": str(arc.get("tone") or "warm"), + "themes": [str(normalize_character_text_refs(item)).strip() for item in arc.get("themes", []) if str(item).strip()], + "orgWebConnection": str(normalize_character_text_refs(arc.get("orgWebConnection") or "")).strip(), + "visibility": str(arc.get("visibility") or "private"), + "chapters": chapters, + "createdDate": str(arc.get("createdDate") or today), + "modifiedDate": today, + } + + +def migrate_hidden_stories(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: story_groups: dict[str, dict[str, Any]] = {} def add(group: str, entry: dict[str, Any], source: str) -> None: @@ -467,23 +538,27 @@ def migrate_hidden_stories(entries: list[dict[str, Any]]) -> list[dict[str, Any] def load_hidden_store() -> dict[str, Any]: migrated = False - if HIDDEN_CONTENT_JSON.exists(): - data = json.loads(HIDDEN_CONTENT_JSON.read_text(encoding="utf-8")) - entries = data.get("entries", []) - stories = data.get("stories", []) - else: - entries = migrate_hidden_entries_from_js() - stories = [] - migrated = True - data = { - "schemaVersion": 2, + if HIDDEN_CONTENT_JSON.exists(): + data = json.loads(HIDDEN_CONTENT_JSON.read_text(encoding="utf-8")) + entries = data.get("entries", []) + stories = data.get("stories", []) + life_arcs = data.get("lifeArcs", []) + else: + entries = migrate_hidden_entries_from_js() + stories = [] + life_arcs = [] + migrated = True + data = { + "schemaVersion": 2, "generatedFrom": "assets/scripts/features/hidden-details.js", - "generatedAt": datetime.now().isoformat(timespec="seconds"), - "entries": entries, - "stories": stories, - } - normalized = [normalize_hidden_entry(entry, entry) for entry in entries] - normalized_stories = [normalize_hidden_story(story, normalized, story) for story in stories] + "generatedAt": datetime.now().isoformat(timespec="seconds"), + "entries": entries, + "stories": stories, + "lifeArcs": life_arcs, + } + normalized = [normalize_hidden_entry(entry, entry) for entry in entries] + normalized_stories = [normalize_hidden_story(story, normalized, story) for story in stories] + normalized_life_arcs = [normalize_life_arc(arc, normalized, normalized_stories, arc) for arc in life_arcs] migrated_relationships = False if not normalized_stories: normalized_stories = migrate_hidden_stories(normalized) @@ -506,10 +581,11 @@ def load_hidden_store() -> dict[str, Any]: "storyMarkers": HIDDEN_STORY_MARKERS, "discoveryStyles": HIDDEN_DISCOVERY_STYLES, "layers": HIDDEN_LAYER_DEPTHS, - "entries": normalized, - "stories": normalized_stories, - "recommendations": hidden_architecture_recommendations(), - } + "entries": normalized, + "stories": normalized_stories, + "lifeArcs": normalized_life_arcs, + "recommendations": hidden_architecture_recommendations(), + } def validate_hidden_integrity(entries: list[dict[str, Any]], stories: list[dict[str, Any]] | None = None) -> dict[str, Any]: @@ -689,18 +765,26 @@ def replace_hidden_editable_block(source: str, entries: list[dict[str, Any]]) -> return source[:start] + generated_hidden_content_block(entries) + "\n" + source[end:] -def save_hidden_store(payload: dict[str, Any]) -> dict[str, Any]: - loaded = load_hidden_store() - current = {entry["id"]: entry for entry in loaded["entries"]} - entries = [normalize_hidden_entry(entry, current.get(str(entry.get("id", "")))) for entry in payload.get("entries", [])] - current_stories = {story["id"]: story for story in loaded.get("stories", [])} - stories = [normalize_hidden_story(story, entries, current_stories.get(str(story.get("id", "")))) for story in payload.get("stories", loaded.get("stories", []))] - ids = [entry["id"] for entry in entries] - if len(ids) != len(set(ids)): - raise ValueError("Entry ids must be unique.") - story_ids = [story["id"] for story in stories] - if len(story_ids) != len(set(story_ids)): - raise ValueError("Story ids must be unique.") +def save_hidden_store(payload: dict[str, Any]) -> dict[str, Any]: + loaded = load_hidden_store() + current = {entry["id"]: entry for entry in loaded["entries"]} + entries = [normalize_hidden_entry(entry, current.get(str(entry.get("id", "")))) for entry in payload.get("entries", [])] + current_stories = {story["id"]: story for story in loaded.get("stories", [])} + stories = [normalize_hidden_story(story, entries, current_stories.get(str(story.get("id", "")))) for story in payload.get("stories", loaded.get("stories", []))] + current_life_arcs = {arc["id"]: arc for arc in loaded.get("lifeArcs", [])} + life_arcs = [ + normalize_life_arc(arc, entries, stories, current_life_arcs.get(str(arc.get("id", "")))) + for arc in payload.get("lifeArcs", loaded.get("lifeArcs", [])) + ] + ids = [entry["id"] for entry in entries] + if len(ids) != len(set(ids)): + raise ValueError("Entry ids must be unique.") + story_ids = [story["id"] for story in stories] + if len(story_ids) != len(set(story_ids)): + raise ValueError("Story ids must be unique.") + life_arc_ids = [arc["id"] for arc in life_arcs] + if len(life_arc_ids) != len(set(life_arc_ids)): + raise ValueError("Life arc ids must be unique.") stamp = datetime.now().strftime("%Y%m%d-%H%M%S") backup_hidden_file(HIDDEN_DETAILS_JS, stamp) backup_hidden_file(HIDDEN_CONTENT_JSON, stamp) @@ -709,10 +793,11 @@ def save_hidden_store(payload: dict[str, Any]) -> dict[str, Any]: json.dumps( { "schemaVersion": 3, - "generatedAt": datetime.now().isoformat(timespec="seconds"), - "entries": entries, - "stories": stories, - }, + "generatedAt": datetime.now().isoformat(timespec="seconds"), + "entries": entries, + "stories": stories, + "lifeArcs": life_arcs, + }, ensure_ascii=False, indent=2, ) + "\n", diff --git a/src/authoring_service/templates.py b/src/authoring_service/templates.py index 91615ae..0f1a04d 100755 --- a/src/authoring_service/templates.py +++ b/src/authoring_service/templates.py @@ -1522,7 +1522,7 @@ HIDDEN_APP_HTML = r""" padding: 12px; font-family: Georgia, "Times New Roman", serif; } - .connection-list, .timeline-list, .story-list, .story-flow-list, .memory-pool { display: grid; gap: 8px; } + .connection-list, .timeline-list, .story-list, .story-flow-list, .memory-pool, .arc-list, .chapter-list, .moment-list { display: grid; gap: 8px; } .connection { text-align: left; display: block; @@ -1543,7 +1543,26 @@ HIDDEN_APP_HTML = r""" border-radius: 8px; background: rgba(251, 241, 220, 0.075); } - .story-card.active { border-color: var(--gold); background: rgba(211, 166, 77, 0.18); } + .story-card.active { border-color: var(--gold); background: rgba(211, 166, 77, 0.18); } + .arc-card, .chapter-card, .moment-card { + width: 100%; + min-height: 0; + height: auto; + display: grid; + gap: 6px; + padding: 9px; + text-align: left; + border-radius: 8px; + background: rgba(251, 241, 220, 0.06); + } + .arc-card.active, .chapter-card.active { border-color: var(--blue); background: rgba(117, 169, 189, 0.16); } + .moment-card { + border: 1px solid rgba(232, 202, 139, 0.14); + color: #efd9b2; + font-size: 13px; + } + .moment-card strong, .arc-card strong, .chapter-card strong { overflow-wrap: anywhere; } + .arc-toolbar { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; } .story-cover { border: 1px solid rgba(232, 202, 139, 0.18); border-radius: 8px; @@ -1898,8 +1917,8 @@ HIDDEN_APP_HTML = r"""
-
-

Story Builder

+
+

Story Builder

@@ -1929,10 +1948,57 @@ HIDDEN_APP_HTML = r"""
-

Memory pool

-
-
-
+

Memory pool

+
+
+
+

Arc Studio

+
Shape long-running life arcs without turning this into a notes vault. Keep raw resources in Obsidian; store the moments that changed the story here.
+
+ + + +
+
+ + + + + + + +
+

Life arcs

+
+

Chapters

+
+
+ + + + + +
+

Log moment

+
+ + + + + + + + + + +
+
+ + +
+
+
+

Memory editor

@@ -2017,9 +2083,11 @@ HIDDEN_APP_HTML = r""" const draftKey = "hiddenNarrativeObservatoryDraft:v1"; const WORLD = { width: 4600, height: 3300, cx: 2300, cy: 1650 }; const SCREEN = { width: 1000, height: 760 }; - const state = { entries: [], stories: [], meta: {}, mode: "graph", editorMode: "memory", selectedId: "", selectedStoryId: "", editingSectionId: "", readerIndex: 0, undo: [], redo: [], draggingId: "", positions: new Map(), clusterPositions: new Map(), zoom: 0, focusCluster: "", focusKind: "", focusIds: null, focusLabel: "", history: [], explorationUndo: [], explorationRedo: [], camera: { x: 0, y: 0, k: 0.22 }, pan: null, drag: null, pendingDrag: null, renderTimer: 0 }; + const state = { entries: [], stories: [], lifeArcs: [], meta: {}, mode: "graph", editorMode: "memory", selectedId: "", selectedStoryId: "", selectedArcId: "", selectedChapterId: "", editingSectionId: "", readerIndex: 0, undo: [], redo: [], draggingId: "", positions: new Map(), clusterPositions: new Map(), zoom: 0, focusCluster: "", focusKind: "", focusIds: null, focusLabel: "", history: [], explorationUndo: [], explorationRedo: [], camera: { x: 0, y: 0, k: 0.22 }, pan: null, drag: null, pendingDrag: null, renderTimer: 0 }; const fields = ["title","type","contentClass","familyLayer","characters","tone","rarity","discoveryDifficulty","mysteryLevel","resonanceScore","pageLocation","triggerConditions","content","surfaces","observatoryRole","canonicalUse","symbols","narrativeArcs","tags","emotionalRole","continuationLinks","echoes","thematicLinks","symbolicLinks","triggerLinks","parentLinks","childLinks","mirroredEntries","enabled","cssClassHooks","audioSettings","animationTrigger","notes"]; - const storyFields = ["storyTitle","storyDescription","storyTone","storyDiscovery","storyCharacters","storySymbols","storyMarkers","storyLayers","storyUnlock","storyHidden"]; + const storyFields = ["storyTitle","storyDescription","storyTone","storyDiscovery","storyCharacters","storySymbols","storyMarkers","storyLayers","storyUnlock","storyHidden"]; + const arcFields = ["arcTitle","arcDomain","arcVisibility","arcTone","arcThemes","arcSummary","arcOrgWeb"]; + const chapterFields = ["chapterTitle","chapterTimeframe","chapterTone","chapterPurpose","chapterStoryDraft"]; const $ = (id) => document.getElementById(id); const toneColors = { warm: "#d3a64d", funny: "#7fb089", nostalgic: "#d06b78", wise: "#75a9bd", strange: "#a58ac9", soft: "#e6bd8c", hopeful: "#9fcf9f", protective: "#d98f71", melancholy: "#8fa7c6" }; const appearanceLabels = { @@ -2052,17 +2120,27 @@ HIDDEN_APP_HTML = r""" function selectedOptions(id) { return Array.from($(id).selectedOptions || []).map((option) => option.value).filter(Boolean); } - function storyById(id = state.selectedStoryId) { - return state.stories.find((story) => story.id === id); - } - function storiesForEntry(entryId) { - return state.stories.filter((story) => (story.nodes || []).includes(entryId)); - } - function storyItems(story) { + function storyById(id = state.selectedStoryId) { + return state.stories.find((story) => story.id === id); + } + function arcById(id = state.selectedArcId) { + return state.lifeArcs.find((arc) => arc.id === id); + } + function chapterById(id = state.selectedChapterId) { + const arc = arcById(); + return (arc?.chapters || []).find((chapter) => chapter.id === id); + } + function storiesForEntry(entryId) { + return state.stories.filter((story) => (story.nodes || []).includes(entryId)); + } + function storyItems(story) { if (!story) return []; if (Array.isArray(story.items) && story.items.length) return story.items; - return (story.nodes || []).map((id) => ({ kind: "memory", id })); - } + return (story.nodes || []).map((id) => ({ kind: "memory", id })); + } + function arcMomentCount(arc) { + return (arc?.chapters || []).reduce((total, chapter) => total + (chapter.moments || []).length, 0); + } function entryById(id) { return state.entries.find((entry) => entry.id === id); } @@ -2248,20 +2326,24 @@ HIDDEN_APP_HTML = r""" window.clearTimeout(state.renderTimer); state.renderTimer = window.setTimeout(render, 90); } - function snapshot() { - state.undo.push(JSON.stringify({ entries: state.entries, stories: state.stories, selectedStoryId: state.selectedStoryId })); + function snapshot() { + state.undo.push(JSON.stringify({ entries: state.entries, stories: state.stories, lifeArcs: state.lifeArcs, selectedStoryId: state.selectedStoryId, selectedArcId: state.selectedArcId, selectedChapterId: state.selectedChapterId })); state.undo = state.undo.slice(-60); state.redo = []; } function restore(serialized) { - const data = JSON.parse(serialized); - state.entries = data.entries || data || []; - state.stories = data.stories || state.stories || []; - state.selectedStoryId = data.selectedStoryId || state.selectedStoryId || state.stories[0]?.id || ""; - rememberDraft(); - render(); - selectNode(state.selectedId || state.entries[0]?.id); - fillStoryForm(storyById()); + const data = JSON.parse(serialized); + state.entries = data.entries || data || []; + state.stories = data.stories || state.stories || []; + state.lifeArcs = data.lifeArcs || state.lifeArcs || []; + state.selectedStoryId = data.selectedStoryId || state.selectedStoryId || state.stories[0]?.id || ""; + state.selectedArcId = data.selectedArcId || state.selectedArcId || state.lifeArcs[0]?.id || ""; + state.selectedChapterId = data.selectedChapterId || state.selectedChapterId || arcById()?.chapters?.[0]?.id || ""; + rememberDraft(); + render(); + selectNode(state.selectedId || state.entries[0]?.id); + fillStoryForm(storyById()); + fillArcForm(arcById()); } function filterState() { return { @@ -2325,10 +2407,10 @@ HIDDEN_APP_HTML = r""" applyExploration(previous); setStatus("one exploration step undone."); } - function rememberDraft() { - localStorage.setItem(draftKey, JSON.stringify({ entries: state.entries, stories: state.stories, selectedId: state.selectedId, selectedStoryId: state.selectedStoryId, savedAt: Date.now() })); - $("autosave").textContent = "local draft kept warm."; - } + function rememberDraft() { + localStorage.setItem(draftKey, JSON.stringify({ entries: state.entries, stories: state.stories, lifeArcs: state.lifeArcs, selectedId: state.selectedId, selectedStoryId: state.selectedStoryId, selectedArcId: state.selectedArcId, selectedChapterId: state.selectedChapterId, savedAt: Date.now() })); + $("autosave").textContent = "local draft kept warm."; + } function depth(entry) { const value = String(entry.familyLayer || "").match(/[0-5]/); if (value) return Number(value[0]); @@ -2417,16 +2499,219 @@ HIDDEN_APP_HTML = r""" markers: selectedOptions("storyMarkers"), }; } - function applyStoryFormToState() { - if (!state.selectedStoryId) return; + function applyStoryFormToState() { + if (!state.selectedStoryId) return; const index = state.stories.findIndex((story) => story.id === state.selectedStoryId); if (index < 0) return; state.stories[index] = storyFromForm(); state.selectedStoryId = state.stories[index].id; rememberDraft(); - renderStoryBuilder(); - render(); - } + renderStoryBuilder(); + render(); + } + function arcFromForm() { + const current = arcById() || {}; + return { + ...current, + id: current.id || `arc-${slugify($("arcTitle").value || "life-arc")}-${Date.now()}`, + title: $("arcTitle").value.trim() || "Untitled life arc", + domain: $("arcDomain").value, + summary: $("arcSummary").value, + tone: $("arcTone").value || "warm", + themes: splitList($("arcThemes").value), + orgWebConnection: $("arcOrgWeb").value, + visibility: $("arcVisibility").value || "private", + chapters: current.chapters || [], + }; + } + function chapterFromForm() { + const current = chapterById() || {}; + return { + ...current, + id: current.id || `chapter-${slugify($("chapterTitle").value || "chapter")}-${Date.now()}`, + title: $("chapterTitle").value.trim() || "Untitled chapter", + timeframe: $("chapterTimeframe").value, + tone: $("chapterTone").value || arcById()?.tone || "warm", + purpose: $("chapterPurpose").value, + storyDraft: $("chapterStoryDraft").value, + linkedStoryId: current.linkedStoryId || "", + moments: current.moments || [], + }; + } + function applyArcFormToState() { + if (!state.selectedArcId) return; + const index = state.lifeArcs.findIndex((arc) => arc.id === state.selectedArcId); + if (index < 0) return; + state.lifeArcs[index] = arcFromForm(); + state.selectedArcId = state.lifeArcs[index].id; + rememberDraft(); + renderArcStudio(); + } + function applyChapterFormToState() { + const arc = arcById(); + if (!arc || !state.selectedChapterId) return; + const index = (arc.chapters || []).findIndex((chapter) => chapter.id === state.selectedChapterId); + if (index < 0) return; + arc.chapters[index] = chapterFromForm(); + state.selectedChapterId = arc.chapters[index].id; + rememberDraft(); + renderArcStudio(); + } + function fillArcForm(arc) { + if (!arc) { + arcFields.concat(chapterFields).forEach((id) => { if ($(id)) $(id).value = ""; }); + $("arcList").innerHTML = ""; + $("chapterList").innerHTML = ""; + $("momentList").innerHTML = ""; + return; + } + state.selectedArcId = arc.id; + $("arcTitle").value = arc.title || ""; + $("arcDomain").value = arc.domain || ""; + $("arcSummary").value = arc.summary || ""; + $("arcTone").value = arc.tone || "warm"; + $("arcThemes").value = (arc.themes || []).join(", "); + $("arcOrgWeb").value = arc.orgWebConnection || ""; + $("arcVisibility").value = arc.visibility || "private"; + if (!state.selectedChapterId || !(arc.chapters || []).some((chapter) => chapter.id === state.selectedChapterId)) { + state.selectedChapterId = arc.chapters?.[0]?.id || ""; + } + fillChapterForm(chapterById()); + renderArcStudio(); + } + function fillChapterForm(chapter) { + if (!chapter) { + chapterFields.forEach((id) => { if ($(id)) $(id).value = ""; }); + $("momentList").innerHTML = "Create a chapter before logging moments."; + return; + } + state.selectedChapterId = chapter.id; + $("chapterTitle").value = chapter.title || ""; + $("chapterTimeframe").value = chapter.timeframe || ""; + $("chapterTone").value = chapter.tone || arcById()?.tone || "warm"; + $("chapterPurpose").value = chapter.purpose || ""; + $("chapterStoryDraft").value = chapter.storyDraft || ""; + renderArcStudio(); + } + function newArc() { + snapshot(); + const arc = { + id: `arc-life-arc-${Date.now()}`, + title: "Untitled life arc", + domain: "", + summary: "", + tone: "warm", + themes: [], + orgWebConnection: "", + visibility: "private", + chapters: [], + createdDate: new Date().toISOString().slice(0, 10), + modifiedDate: new Date().toISOString().slice(0, 10), + }; + state.lifeArcs.unshift(arc); + state.selectedArcId = arc.id; + state.selectedChapterId = ""; + fillArcForm(arc); + rememberDraft(); + } + function deleteArc() { + const arc = arcById(); + if (!arc || !confirm(`Let "${arc.title}" rest outside the arc studio?`)) return; + snapshot(); + state.lifeArcs = state.lifeArcs.filter((item) => item.id !== arc.id); + state.selectedArcId = state.lifeArcs[0]?.id || ""; + state.selectedChapterId = arcById()?.chapters?.[0]?.id || ""; + fillArcForm(arcById()); + rememberDraft(); + } + function newChapter() { + if (!arcById()) newArc(); + const arc = arcById(); + if (!arc) return; + snapshot(); + const chapter = { + id: `chapter-${slugify(arc.title)}-${Date.now()}`, + title: "Untitled chapter", + timeframe: "", + tone: arc.tone || "warm", + purpose: "", + storyDraft: "", + linkedStoryId: "", + moments: [], + }; + arc.chapters = [chapter, ...(arc.chapters || [])]; + state.selectedChapterId = chapter.id; + fillChapterForm(chapter); + rememberDraft(); + } + function deleteChapter() { + const arc = arcById(); + const chapter = chapterById(); + if (!arc || !chapter || !confirm(`Remove chapter "${chapter.title}" from this arc?`)) return; + snapshot(); + arc.chapters = (arc.chapters || []).filter((item) => item.id !== chapter.id); + state.selectedChapterId = arc.chapters[0]?.id || ""; + fillChapterForm(chapterById()); + rememberDraft(); + } + function addMoment() { + const chapter = chapterById(); + if (!chapter) return; + const text = $("momentText").value.trim(); + const lesson = $("momentLesson").value.trim(); + if (!text && !lesson) { + setStatus("a moment needs either an event or a lesson."); + return; + } + snapshot(); + chapter.moments = [{ + id: `moment-${Date.now()}`, + date: $("momentDate").value, + kind: $("momentKind").value || "moment", + text, + feeling: $("momentFeeling").value, + lesson, + metric: $("momentMetric").value, + sourcePointer: $("momentSource").value, + visibility: $("momentVisibility").value || "private", + linkedStoryId: $("momentStoryLink").value, + linkedEntryId: $("momentEntryLink").value, + }, ...(chapter.moments || [])]; + ["momentText","momentFeeling","momentMetric","momentLesson","momentSource"].forEach((id) => $(id).value = ""); + renderArcStudio(); + rememberDraft(); + } + function deleteMoment(momentId) { + const chapter = chapterById(); + if (!chapter) return; + snapshot(); + chapter.moments = (chapter.moments || []).filter((moment) => moment.id !== momentId); + renderArcStudio(); + rememberDraft(); + } + function renderArcStudio() { + const arc = arcById(); + const chapter = chapterById(); + $("arcList").innerHTML = state.lifeArcs.length + ? state.lifeArcs.map((item) => ``).join("") + : "No life arcs yet."; + $("arcList").querySelectorAll("[data-arc]").forEach((button) => button.addEventListener("click", () => { + state.selectedArcId = button.dataset.arc; + state.selectedChapterId = arcById()?.chapters?.[0]?.id || ""; + fillArcForm(arcById()); + })); + $("chapterList").innerHTML = arc?.chapters?.length + ? arc.chapters.map((item) => ``).join("") + : "No chapters in this arc yet."; + $("chapterList").querySelectorAll("[data-chapter]").forEach((button) => button.addEventListener("click", () => { + state.selectedChapterId = button.dataset.chapter; + fillChapterForm(chapterById()); + })); + $("momentList").innerHTML = chapter?.moments?.length + ? chapter.moments.map((moment) => `
${html(moment.date || moment.kind || "moment")}${html(moment.text || moment.lesson)}${[moment.feeling, moment.metric, moment.sourcePointer, moment.visibility].filter(Boolean).map(html).join(" / ")}
`).join("") + : "Log compact moments here, then turn selected material into the chapter story draft."; + $("momentList").querySelectorAll("[data-delete-moment]").forEach((button) => button.addEventListener("click", () => deleteMoment(button.dataset.deleteMoment))); + } function fillStoryForm(story) { if (!story) { $("storyCover").innerHTML = "Create a story to shape a route through the observatory."; @@ -3902,9 +4187,11 @@ HIDDEN_APP_HTML = r""" const opt = (value, label = value) => ``; $("type").innerHTML = state.meta.types.map((value) => opt(value)).join(""); $("contentClass").innerHTML = (state.meta.contentClasses || ["fragment","memory"]).map((value) => opt(value)).join(""); - $("tone").innerHTML = state.meta.tones.map((value) => opt(value)).join(""); - $("storyTone").innerHTML = state.meta.tones.map((value) => opt(value)).join(""); - $("storyDiscovery").innerHTML = (state.meta.discoveryStyles || ["gradual"]).map((value) => opt(value)).join(""); + $("tone").innerHTML = state.meta.tones.map((value) => opt(value)).join(""); + $("storyTone").innerHTML = state.meta.tones.map((value) => opt(value)).join(""); + $("arcTone").innerHTML = state.meta.tones.map((value) => opt(value)).join(""); + $("chapterTone").innerHTML = state.meta.tones.map((value) => opt(value)).join(""); + $("storyDiscovery").innerHTML = (state.meta.discoveryStyles || ["gradual"]).map((value) => opt(value)).join(""); $("storyMarkers").innerHTML = (state.meta.storyMarkers || []).map((value) => opt(value)).join(""); $("rarity").innerHTML = state.meta.rarities.map((value) => opt(value)).join(""); $("layerFilter").innerHTML = opt("", "All depths") + (state.meta.layers || []).map((layer) => opt(layer.id, `Layer ${layer.id} - ${layer.name}`)).join(""); @@ -3918,7 +4205,7 @@ HIDDEN_APP_HTML = r""" const territory = config.observatory?.territory || "memory territory"; return ``; }).join(""); - $("characterAtlas").querySelectorAll("[data-character]").forEach((button) => button.addEventListener("click", () => { + $("characterAtlas").querySelectorAll("[data-character]").forEach((button) => button.addEventListener("click", () => { snapshotExploration(`character territory: ${button.dataset.character}`); $("characterFilter").value = button.dataset.character; state.mode = "character"; @@ -3928,22 +4215,34 @@ HIDDEN_APP_HTML = r""" $("modebar").querySelectorAll("button").forEach((item) => item.classList.toggle("active", item.dataset.mode === state.mode)); render(); window.setTimeout(() => fitCameraTo(boundsForEntries(visibleScope().entries), true), 0); - })); - renderStoryBuilder(); - } - async function save() { - applyFormToState(); - applyStoryFormToState(); - setStatus("Saving the constellation."); - try { - const data = await api("/api/hidden", { method: "POST", body: JSON.stringify({ entries: state.entries, stories: state.stories }) }); - state.entries = data.entries; - state.stories = data.stories || []; - state.meta = data; + })); + refreshArcLinkOptions(); + renderStoryBuilder(); + renderArcStudio(); + } + function refreshArcLinkOptions() { + const opt = (value, label = value) => ``; + $("momentStoryLink").innerHTML = opt("", "No story link") + state.stories.map((story) => opt(story.id, story.title)).join(""); + $("momentEntryLink").innerHTML = opt("", "No memory link") + state.entries.map((entry) => opt(entry.id, entry.title)).join(""); + } + async function save() { + applyFormToState(); + applyStoryFormToState(); + applyArcFormToState(); + applyChapterFormToState(); + setStatus("Saving the constellation."); + try { + const data = await api("/api/hidden", { method: "POST", body: JSON.stringify({ entries: state.entries, stories: state.stories, lifeArcs: state.lifeArcs }) }); + state.entries = data.entries; + state.stories = data.stories || []; + state.lifeArcs = data.lifeArcs || []; + state.meta = data; localStorage.removeItem(draftKey); setStatus(data.queuedBuild ? "constellation stored safely. asset republish queued." : (data.message || "constellation stored safely.")); - renderValidation(); - render(); + renderValidation(); + refreshArcLinkOptions(); + renderArcStudio(); + render(); } catch (err) { setStatus(err.message); } @@ -3952,21 +4251,28 @@ HIDDEN_APP_HTML = r""" try { const data = await api("/api/hidden"); state.meta = data; - state.entries = data.entries || []; - state.stories = data.stories || []; - const draft = JSON.parse(localStorage.getItem(draftKey) || "null"); - if (draft?.entries?.length && confirm("A local constellation draft exists. Restore it?")) { - state.entries = draft.entries; - state.stories = draft.stories || state.stories; - state.selectedId = draft.selectedId || ""; - state.selectedStoryId = draft.selectedStoryId || ""; - } + state.entries = data.entries || []; + state.stories = data.stories || []; + state.lifeArcs = data.lifeArcs || []; + const draft = JSON.parse(localStorage.getItem(draftKey) || "null"); + if ((draft?.entries?.length || draft?.stories?.length || draft?.lifeArcs?.length) && confirm("A local constellation draft exists. Restore it?")) { + state.entries = draft.entries; + state.stories = draft.stories || state.stories; + state.lifeArcs = draft.lifeArcs || state.lifeArcs; + state.selectedId = draft.selectedId || ""; + state.selectedStoryId = draft.selectedStoryId || ""; + state.selectedArcId = draft.selectedArcId || ""; + state.selectedChapterId = draft.selectedChapterId || ""; + } populateControls(); renderValidation(); - state.selectedStoryId = state.selectedStoryId || state.stories[0]?.id || ""; - setStatus(data.migratedRelationshipsToStories ? "legacy relationships were gathered into calmer story routes." : data.migratedFromJs ? "Existing hidden details became a memory constellation." : "observatory open."); - selectNode(state.selectedId || state.entries[0]?.id); - fillStoryForm(storyById()); + state.selectedStoryId = state.selectedStoryId || state.stories[0]?.id || ""; + state.selectedArcId = state.selectedArcId || state.lifeArcs[0]?.id || ""; + state.selectedChapterId = state.selectedChapterId || arcById()?.chapters?.[0]?.id || ""; + setStatus(data.migratedRelationshipsToStories ? "legacy relationships were gathered into calmer story routes." : data.migratedFromJs ? "Existing hidden details became a memory constellation." : "observatory open."); + selectNode(state.selectedId || state.entries[0]?.id); + fillStoryForm(storyById()); + fillArcForm(arcById()); fitCameraTo({ minX: 250, minY: 220, maxX: WORLD.width - 250, maxY: WORLD.height - 220 }, false); render(); } catch (err) { @@ -4068,10 +4374,18 @@ HIDDEN_APP_HTML = r""" applyTypeDefaults(); applyFormToState(); }); - storyFields.forEach((id) => { - $(id).addEventListener("input", applyStoryFormToState); - $(id).addEventListener("change", () => { snapshot(); applyStoryFormToState(); }); - }); + storyFields.forEach((id) => { + $(id).addEventListener("input", applyStoryFormToState); + $(id).addEventListener("change", () => { snapshot(); applyStoryFormToState(); }); + }); + arcFields.forEach((id) => { + $(id).addEventListener("input", applyArcFormToState); + $(id).addEventListener("change", () => { snapshot(); applyArcFormToState(); }); + }); + chapterFields.forEach((id) => { + $(id).addEventListener("input", applyChapterFormToState); + $(id).addEventListener("change", () => { snapshot(); applyChapterFormToState(); }); + }); let filterSnapshotTimer = 0; ["search","layerFilter","characterFilter","toneFilter","rarityFilter"].forEach((id) => { $(id).addEventListener("focus", () => { @@ -4092,7 +4406,12 @@ HIDDEN_APP_HTML = r""" }); }); $("newBtn").onclick = () => newEntry(); - $("newStoryBtn").onclick = () => newStory(); + $("newStoryBtn").onclick = () => newStory(); + $("newArcBtn").onclick = newArc; + $("newChapterBtn").onclick = newChapter; + $("deleteArcBtn").onclick = deleteArc; + $("deleteChapterBtn").onclick = deleteChapter; + $("addMomentBtn").onclick = addMoment; $("storyFromSelectionBtn").onclick = () => newStory(state.selectedId); $("addSectionBtn").onclick = addStorySection; $("saveSectionBtn").onclick = saveStorySection; @@ -4113,8 +4432,8 @@ HIDDEN_APP_HTML = r""" $("readerNextBtn").onclick = () => stepStoryReader(1); $("duplicateBtn").onclick = duplicateEntry; $("deleteBtn").onclick = deleteEntry; - $("undoBtn").onclick = () => { if (!state.undo.length) return; state.redo.push(JSON.stringify({ entries: state.entries, stories: state.stories, selectedStoryId: state.selectedStoryId })); restore(state.undo.pop()); }; - $("redoBtn").onclick = () => { if (!state.redo.length) return; state.undo.push(JSON.stringify({ entries: state.entries, stories: state.stories, selectedStoryId: state.selectedStoryId })); restore(state.redo.pop()); }; + $("undoBtn").onclick = () => { if (!state.undo.length) return; state.redo.push(JSON.stringify({ entries: state.entries, stories: state.stories, lifeArcs: state.lifeArcs, selectedStoryId: state.selectedStoryId, selectedArcId: state.selectedArcId, selectedChapterId: state.selectedChapterId })); restore(state.undo.pop()); }; + $("redoBtn").onclick = () => { if (!state.redo.length) return; state.undo.push(JSON.stringify({ entries: state.entries, stories: state.stories, lifeArcs: state.lifeArcs, selectedStoryId: state.selectedStoryId, selectedArcId: state.selectedArcId, selectedChapterId: state.selectedChapterId })); restore(state.redo.pop()); }; $("modebar").querySelectorAll("button").forEach((button) => button.addEventListener("click", () => { snapshotExploration(`switch to ${button.dataset.mode}`); state.mode = button.dataset.mode; diff --git a/src/tests/test_authoring_server.py b/src/tests/test_authoring_server.py index 7b8b78c..d4e0e79 100755 --- a/src/tests/test_authoring_server.py +++ b/src/tests/test_authoring_server.py @@ -8,10 +8,11 @@ from unittest import mock sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -import authoring_service.build as build_server -import authoring_service.config as config_server -import authoring_service.content as server -import authoring_service.utils as utils_server +import authoring_service.build as build_server +import authoring_service.config as config_server +import authoring_service.content as server +import authoring_service.hidden as hidden_server +import authoring_service.utils as utils_server class AuthoringServerTestCase(unittest.TestCase): @@ -123,12 +124,79 @@ class UtilityTests(AuthoringServerTestCase): filename, payload, page_path = server.parse_upload_form(content_type, body) - self.assertEqual(filename, "photo.png") - self.assertEqual(payload, b"image bytes") - self.assertEqual(page_path, "lima/index.md") - - -class PageRenderingTests(AuthoringServerTestCase): + self.assertEqual(filename, "photo.png") + self.assertEqual(payload, b"image bytes") + self.assertEqual(page_path, "lima/index.md") + + +class HiddenLifeArcTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.content_json = self.root / "assets" / "content" / "hidden-details.json" + self.hidden_js = self.root / "assets" / "scripts" / "features" / "hidden-details.js" + self.backup_dir = self.root / "backups" / "hidden-details" + self.content_json.parent.mkdir(parents=True) + self.hidden_js.parent.mkdir(parents=True) + self.hidden_js.write_text( + "(function(){\n" + " // -----------------------------\n" + " // EDITABLE CONTENT\n" + " const familyLayers = [];\n" + " // -----------------------------\n" + " // STATE HELPERS\n" + "})();\n", + encoding="utf-8", + ) + self.content_json.write_text('{"schemaVersion":3,"entries":[],"stories":[],"lifeArcs":[]}', encoding="utf-8") + self.patchers = [ + mock.patch.object(hidden_server, "ROOT", self.root), + mock.patch.object(hidden_server, "HIDDEN_CONTENT_JSON", self.content_json), + mock.patch.object(hidden_server, "HIDDEN_DETAILS_JS", self.hidden_js), + mock.patch.object(hidden_server, "HIDDEN_BACKUP_DIR", self.backup_dir), + ] + for patcher in self.patchers: + patcher.start() + + def tearDown(self): + for patcher in reversed(self.patchers): + patcher.stop() + self.tmp.cleanup() + + def test_save_hidden_store_preserves_life_arcs(self): + saved = hidden_server.save_hidden_store({ + "entries": [], + "stories": [], + "lifeArcs": [{ + "title": "Gym Journey", + "domain": "fitness", + "themes": ["consistency", "strength"], + "visibility": "private", + "chapters": [{ + "title": "Starting Again", + "timeframe": "Summer 2026", + "purpose": "becoming someone who returns", + "storyDraft": "The first victory was returning.", + "moments": [{ + "date": "2026-07-09", + "kind": "milestone", + "text": "First week back in the gym.", + "feeling": "steady", + "metric": "3 sessions", + "sourcePointer": "Obsidian: Gym", + }], + }], + }], + }) + + self.assertEqual(saved["lifeArcs"][0]["title"], "Gym Journey") + self.assertEqual(saved["lifeArcs"][0]["chapters"][0]["moments"][0]["metric"], "3 sessions") + reloaded = hidden_server.load_hidden_store() + self.assertEqual(reloaded["lifeArcs"][0]["themes"], ["consistency", "strength"]) + self.assertEqual(reloaded["lifeArcs"][0]["chapters"][0]["storyDraft"], "The first victory was returning.") + + +class PageRenderingTests(AuthoringServerTestCase): def test_render_org_writes_metadata_and_body(self): rendered = server.render_org( {