feat: implement multi-tab synchronization and updatedAt timestamps for project, chapter, scene, and content management

This commit is contained in:
aomukai 2025-11-23 22:02:48 +09:00
parent f1f4f721ed
commit 805bd9f3eb
8 changed files with 380 additions and 10 deletions

View file

@ -19,6 +19,7 @@
<script defer src="src/modules/compendium-manager.js"></script>
<script defer src="src/modules/ai-settings.js"></script>
<script defer src="src/modules/github-backup.js"></script>
<script defer src="src/modules/tab-sync.js"></script>
<script defer src="src/workshop.js"></script>
<script defer src="src/update-checker.js"></script>
<script defer src="src/tts.js"></script>

View file

@ -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 {

View file

@ -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) {

View file

@ -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);

View file

@ -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) {

178
src/modules/tab-sync.js Normal file
View file

@ -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
};
})();

View file

@ -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 */ }

View file

@ -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',