From 805bd9f3eb498dc5ac69f35e44047f6c4fa80e3a Mon Sep 17 00:00:00 2001 From: aomukai Date: Sun, 23 Nov 2025 22:02:48 +0900 Subject: [PATCH] feat: implement multi-tab synchronization and updatedAt timestamps for project, chapter, scene, and content management --- main.html | 1 + src/app.js | 54 ++++++++++ src/modules/chapter-manager.js | 22 +++- src/modules/project-manager.js | 38 ++++++- src/modules/scene-manager.js | 35 ++++++- src/modules/tab-sync.js | 178 +++++++++++++++++++++++++++++++++ src/save-utils.js | 60 ++++++++++- src/update-checker.js | 2 +- 8 files changed, 380 insertions(+), 10 deletions(-) create mode 100644 src/modules/tab-sync.js diff --git a/main.html b/main.html index 3f9e8da..9925858 100644 --- a/main.html +++ b/main.html @@ -19,6 +19,7 @@ + diff --git a/src/app.js b/src/app.js index 6469cc1..0c1b40a 100644 --- a/src/app.js +++ b/src/app.js @@ -179,6 +179,50 @@ db.version(7).stores({ // noop: new table will be created automatically }); +// Add updatedAt timestamps for multi-tab sync (v8) +db.version(8).stores({ + projects: 'id, name, created, modified, updatedAt', + chapters: 'id, projectId, title, order, created, modified, updatedAt', + scenes: 'id, projectId, chapterId, title, order, created, modified, updatedAt', + content: 'sceneId, text, wordCount, updatedAt', + prompts: 'id, projectId, category, title, created, modified, updatedAt', + codex: 'id, projectId, title, created, modified, updatedAt', + compendium: 'id, [projectId+category], projectId, category, title, modified, tags, updatedAt', + promptHistory: 'id, projectId, sceneId, timestamp, beat, prompt', + workshopSessions: 'id, projectId, name, createdAt, updatedAt' +}).upgrade(async tx => { + // Add updatedAt to existing records + const now = Date.now(); + + await tx.table('projects').toCollection().modify(proj => { + if (!proj.updatedAt) proj.updatedAt = now; + }); + + await tx.table('chapters').toCollection().modify(ch => { + if (!ch.updatedAt) ch.updatedAt = now; + }); + + await tx.table('scenes').toCollection().modify(sc => { + if (!sc.updatedAt) sc.updatedAt = now; + }); + + await tx.table('content').toCollection().modify(cont => { + if (!cont.updatedAt) cont.updatedAt = now; + }); + + await tx.table('prompts').toCollection().modify(pr => { + if (!pr.updatedAt) pr.updatedAt = now; + }); + + await tx.table('codex').toCollection().modify(cd => { + if (!cd.updatedAt) cd.updatedAt = now; + }); + + await tx.table('compendium').toCollection().modify(comp => { + if (!comp.updatedAt) comp.updatedAt = now; + }); +}); + // Expose the global Dexie instance for debugging and console usage try { window.db = window.db || db; } catch (e) { /* ignore in non-browser env */ } @@ -589,6 +633,16 @@ document.addEventListener('alpine:init', () => { this.updateLoadingScreen(50, 'Initializing AI...', 'This may take 2-3 minutes on first run...'); + // Initialize tab sync for multi-tab coordination + if (window.TabSync && typeof window.TabSync.init === 'function') { + try { + window.TabSync.init(this); + console.log('✅ Multi-tab sync initialized'); + } catch (e) { + console.warn('Tab sync init failed:', e); + } + } + // Initialize AI via extracted module (src/ai.js) if (window.AI && typeof window.AI.init === 'function') { try { diff --git a/src/modules/chapter-manager.js b/src/modules/chapter-manager.js index 191e883..3e90474 100644 --- a/src/modules/chapter-manager.js +++ b/src/modules/chapter-manager.js @@ -27,10 +27,21 @@ title: chapterName, order: app.chapters.length, created: new Date(), - modified: new Date() + modified: new Date(), + updatedAt: Date.now() }; await db.chapters.add(chapter); + + // Broadcast chapter creation + if (window.TabSync) { + window.TabSync.broadcast(window.TabSync.MSG_TYPES.CHAPTER_SAVED, { + id: chapter.id, + projectId: chapter.projectId, + updatedAt: chapter.updatedAt + }); + } + app.showNewChapterModal = false; app.newChapterName = ''; @@ -100,6 +111,15 @@ } await db.chapters.delete(chapterId); + + // Broadcast chapter deletion + if (window.TabSync) { + window.TabSync.broadcast(window.TabSync.MSG_TYPES.CHAPTER_DELETED, { + id: chapterId, + projectId: app.currentProject.id + }); + } + if (app.currentChapter && app.currentChapter.id === chapterId) app.currentChapter = target || null; await app.normalizeAllOrders(); } catch (e) { diff --git a/src/modules/project-manager.js b/src/modules/project-manager.js index 7882c64..57d2dcd 100644 --- a/src/modules/project-manager.js +++ b/src/modules/project-manager.js @@ -10,14 +10,25 @@ async createProject(app, projectName) { if (!projectName) return; + const now = Date.now(); const project = { - id: Date.now().toString(), + id: now.toString(), name: projectName, created: new Date(), - modified: new Date() + modified: new Date(), + updatedAt: now }; await db.projects.add(project); + + // Broadcast project creation + if (window.TabSync) { + window.TabSync.broadcast(window.TabSync.MSG_TYPES.PROJECT_SAVED, { + id: project.id, + updatedAt: project.updatedAt + }); + } + app.currentProject = project; app.showNewProjectModal = false; app.newProjectName = ''; @@ -231,7 +242,21 @@ async renameCurrentProject(app, newName) { if (!app.currentProject || !newName) return; try { - await db.projects.update(app.currentProject.id, { name: newName, modified: new Date() }); + const now = Date.now(); + await db.projects.update(app.currentProject.id, { + name: newName, + modified: new Date(), + updatedAt: now + }); + + // Broadcast project update + if (window.TabSync) { + window.TabSync.broadcast(window.TabSync.MSG_TYPES.PROJECT_SAVED, { + id: app.currentProject.id, + updatedAt: now + }); + } + await this.loadProjects(app); // refresh currentProject reference app.currentProject = await db.projects.get(app.currentProject.id); @@ -271,6 +296,13 @@ // Delete project itself await db.projects.delete(projectId); + // Broadcast project deletion + if (window.TabSync) { + window.TabSync.broadcast(window.TabSync.MSG_TYPES.PROJECT_DELETED, { + id: projectId + }); + } + // Reload projects list await this.loadProjects(app); diff --git a/src/modules/scene-manager.js b/src/modules/scene-manager.js index 7cd5bfa..2c031e4 100644 --- a/src/modules/scene-manager.js +++ b/src/modules/scene-manager.js @@ -35,8 +35,9 @@ const targetChapter = app.currentChapter || app.chapters[0]; + const now = Date.now(); const scene = { - id: Date.now().toString(), + id: now.toString(), projectId: app.currentProject.id, chapterId: targetChapter.id, title: sceneName, @@ -46,16 +47,28 @@ pov: app.pov || '3rd person limited', tense: app.tense || 'past', created: new Date(), - modified: new Date() + modified: new Date(), + updatedAt: now }; await db.scenes.add(scene); await db.content.add({ sceneId: scene.id, text: '', - wordCount: 0 + wordCount: 0, + updatedAt: now }); + // Broadcast scene creation + if (window.TabSync) { + window.TabSync.broadcast(window.TabSync.MSG_TYPES.SCENE_SAVED, { + id: scene.id, + projectId: scene.projectId, + chapterId: scene.chapterId, + updatedAt: scene.updatedAt + }); + } + app.showNewSceneModal = false; app.newSceneName = ''; @@ -90,7 +103,10 @@ app.currentScene = { ...scene, - content: cleanContent + content: cleanContent, + // Track when this version was loaded for conflict detection + loadedUpdatedAt: scene.updatedAt || Date.now(), + contentLoadedUpdatedAt: content?.updatedAt || Date.now() }; // Load scene-specific generation options into UI state @@ -187,8 +203,19 @@ async deleteScene(app, sceneId) { if (!confirm('Delete this scene? This cannot be undone.')) return; try { + const scene = await db.scenes.get(sceneId); await db.scenes.delete(sceneId); await db.content.delete(sceneId); + + // Broadcast scene deletion + if (window.TabSync && scene) { + window.TabSync.broadcast(window.TabSync.MSG_TYPES.SCENE_DELETED, { + id: sceneId, + projectId: scene.projectId, + chapterId: scene.chapterId + }); + } + if (app.currentScene && app.currentScene.id === sceneId) app.currentScene = null; await app.normalizeAllOrders(); } catch (e) { diff --git a/src/modules/tab-sync.js b/src/modules/tab-sync.js new file mode 100644 index 0000000..727be51 --- /dev/null +++ b/src/modules/tab-sync.js @@ -0,0 +1,178 @@ +// Tab synchronization using BroadcastChannel API +// This module handles syncing changes between multiple tabs of the application +(function () { + const CHANNEL_NAME = 'writingway-sync'; + let channel = null; + let app = null; + + // Message types + const MSG_TYPES = { + PROJECT_SAVED: 'project:saved', + CHAPTER_SAVED: 'chapter:saved', + SCENE_SAVED: 'scene:saved', + CONTENT_SAVED: 'content:saved', + PROJECT_DELETED: 'project:deleted', + CHAPTER_DELETED: 'chapter:deleted', + SCENE_DELETED: 'scene:deleted', + PROMPT_SAVED: 'prompt:saved', + CODEX_SAVED: 'codex:saved', + COMPENDIUM_SAVED: 'compendium:saved', + COMPENDIUM_DELETED: 'compendium:deleted', + WORKSHOP_SAVED: 'workshop:saved' + }; + + function init(appInstance) { + if (!window.BroadcastChannel) { + console.warn('BroadcastChannel not supported - multi-tab sync disabled'); + return; + } + + app = appInstance; + channel = new BroadcastChannel(CHANNEL_NAME); + + channel.onmessage = async (event) => { + const { type, data } = event.data; + console.log('📡 Received sync message:', type, data); + + try { + await handleMessage(type, data); + } catch (e) { + console.error('Failed to handle sync message:', e); + } + }; + + console.log('✅ Tab sync initialized'); + } + + async function handleMessage(type, data) { + if (!app) return; + + switch (type) { + case MSG_TYPES.PROJECT_SAVED: + // Reload projects list if on project selection screen + if (!app.currentProject || app.currentProject.id === data.id) { + await app.loadProjects?.(); + } + break; + + case MSG_TYPES.CHAPTER_SAVED: + // Reload chapters if viewing this project + if (app.currentProject?.id === data.projectId) { + await app.loadChapters?.(); + } + break; + + case MSG_TYPES.SCENE_SAVED: + // Reload scene if it's currently open in another tab + if (app.currentScene?.id === data.id) { + const updatedScene = await db.scenes.get(data.id); + if (updatedScene && updatedScene.updatedAt !== app.currentScene.updatedAt) { + // Scene was modified in another tab + const shouldReload = confirm( + `This scene was modified in another tab.\n\n` + + `Click OK to reload the latest version, or Cancel to keep your current changes.` + ); + if (shouldReload) { + await app.loadScene?.(data.id); + } + } + } else if (app.currentProject?.id === data.projectId) { + // Refresh scene list if viewing same project but different scene + await app.loadChapters?.(); + } + break; + + case MSG_TYPES.CONTENT_SAVED: + // If viewing the same scene, check for conflicts + if (app.currentScene?.id === data.sceneId) { + const updatedContent = await db.content.get(data.sceneId); + if (updatedContent && app.currentScene.content) { + const currentContentTimestamp = app.currentScene.contentUpdatedAt || 0; + if (updatedContent.updatedAt > currentContentTimestamp) { + const shouldReload = confirm( + `This scene's content was modified in another tab.\n\n` + + `Click OK to reload the latest version, or Cancel to keep your current changes.` + ); + if (shouldReload) { + await app.loadScene?.(data.sceneId); + } + } + } + } + break; + + case MSG_TYPES.PROJECT_DELETED: + if (app.currentProject?.id === data.id) { + alert('This project was deleted in another tab.'); + app.currentProject = null; + await app.loadProjects?.(); + } else { + await app.loadProjects?.(); + } + break; + + case MSG_TYPES.CHAPTER_DELETED: + if (app.currentProject?.id === data.projectId) { + await app.loadChapters?.(); + } + break; + + case MSG_TYPES.SCENE_DELETED: + if (app.currentScene?.id === data.id) { + alert('This scene was deleted in another tab.'); + app.currentScene = null; + await app.loadChapters?.(); + } else if (app.currentProject?.id === data.projectId) { + await app.loadChapters?.(); + } + break; + + case MSG_TYPES.PROMPT_SAVED: + case MSG_TYPES.CODEX_SAVED: + // Reload if viewing same project + if (app.currentProject?.id === data.projectId) { + await app.loadAllPrompts?.(); + } + break; + + case MSG_TYPES.COMPENDIUM_SAVED: + case MSG_TYPES.COMPENDIUM_DELETED: + // Reload compendium if viewing same project + if (app.currentProject?.id === data.projectId) { + await app.loadCompendium?.(); + } + break; + + case MSG_TYPES.WORKSHOP_SAVED: + // Reload workshop sessions if viewing same project + if (app.currentProject?.id === data.projectId) { + await app.loadWorkshopSessions?.(); + } + break; + } + } + + function broadcast(type, data) { + if (!channel) return; + + const message = { type, data, timestamp: Date.now() }; + console.log('📤 Broadcasting:', message); + channel.postMessage(message); + } + + function destroy() { + if (channel) { + channel.close(); + channel = null; + } + app = null; + } + + // Export module + window.TabSync = { + init, + broadcast, + destroy, + MSG_TYPES + }; +})(); diff --git a/src/save-utils.js b/src/save-utils.js index 7997f39..ce36d71 100644 --- a/src/save-utils.js +++ b/src/save-utils.js @@ -13,6 +13,42 @@ app.isSaving = false; return false; } + + // Check for conflicts: if scene has loadedUpdatedAt, verify it hasn't changed + if (scene.loadedUpdatedAt) { + const dbScene = await db.scenes.get(scene.id); + if (dbScene && dbScene.updatedAt && dbScene.updatedAt > scene.loadedUpdatedAt) { + const shouldOverwrite = confirm( + `Warning: This scene was modified in another tab since you loaded it.\n\n` + + `Click OK to overwrite with your changes, or Cancel to reload the latest version.` + ); + if (!shouldOverwrite) { + app.isSaving = false; + app.saveStatus = 'Cancelled'; + // Reload the scene with latest version + await app.loadScene?.(scene.id); + return false; + } + } + } + + // Check for content conflicts + if (scene.contentLoadedUpdatedAt) { + const dbContent = await db.content.get(scene.id); + if (dbContent && dbContent.updatedAt && dbContent.updatedAt > scene.contentLoadedUpdatedAt) { + const shouldOverwrite = confirm( + `Warning: This scene's content was modified in another tab since you loaded it.\n\n` + + `Click OK to overwrite with your changes, or Cancel to reload the latest version.` + ); + if (!shouldOverwrite) { + app.isSaving = false; + app.saveStatus = 'Cancelled'; + await app.loadScene?.(scene.id); + return false; + } + } + } + // compute word count from scene content const contentText = (scene.content || '').trim(); const wordCount = contentText ? contentText.split(/\s+/).filter(w => w.length > 0).length : 0; @@ -28,15 +64,26 @@ } catch (e) { /* ignore */ } // persist content and scene metadata using the global `db` instance + const now = Date.now(); const contentRecord = { sceneId: scene.id, text: scene.content || '', wordCount: wordCount, - modified: new Date() + modified: new Date(), + updatedAt: now }; await db.content.put(contentRecord); + // Broadcast content change + if (window.TabSync) { + window.TabSync.broadcast(window.TabSync.MSG_TYPES.CONTENT_SAVED, { + sceneId: scene.id, + projectId: scene.projectId, + updatedAt: now + }); + } + const scenePatch = { id: scene.id, projectId: scene.projectId || (app.currentProject && app.currentProject.id) || null, @@ -49,6 +96,7 @@ pov: (app.pov !== undefined ? app.pov : (scene.pov || '')), tense: (app.tense !== undefined ? app.tense : (scene.tense || '')), modified: new Date(), + updatedAt: now, wordCount }; @@ -85,6 +133,16 @@ let mergedScene = null; try { mergedScene = await safeMergeUpdate(scene.id, scenePatch); + + // Broadcast scene change + if (window.TabSync && mergedScene) { + window.TabSync.broadcast(window.TabSync.MSG_TYPES.SCENE_SAVED, { + id: scene.id, + projectId: scenePatch.projectId, + chapterId: scenePatch.chapterId, + updatedAt: scenePatch.updatedAt + }); + } } catch (e) { // If safe merge fails, fallback to update/put already handled inside helper try { mergedScene = await db.scenes.get(scene.id); } catch (err) { /* ignore */ } diff --git a/src/update-checker.js b/src/update-checker.js index 01163c3..c34a0c5 100644 --- a/src/update-checker.js +++ b/src/update-checker.js @@ -4,7 +4,7 @@ const UpdateChecker = { // Build timestamp - update this when you push a new version // This represents when this version was created - buildDate: new Date('2025-11-23T21:40:20Z').getTime(), // Update before each push + buildDate: new Date('2025-11-23T22:00:20Z').getTime(), // Update before each push // GitHub repository info repoOwner: 'aomukai',