feat: implement multi-tab synchronization and updatedAt timestamps for project, chapter, scene, and content management
This commit is contained in:
parent
f1f4f721ed
commit
805bd9f3eb
8 changed files with 380 additions and 10 deletions
|
|
@ -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>
|
||||
|
|
|
|||
54
src/app.js
54
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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
178
src/modules/tab-sync.js
Normal 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
|
||||
};
|
||||
})();
|
||||
|
|
@ -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 */ }
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue