diff --git a/app.js b/app.js index 350abdf..c0fd0d4 100644 --- a/app.js +++ b/app.js @@ -15,6 +15,15 @@ import { mergeSyncData } from './sync-snapshot.js'; const DEFAULT_LIST_ID = 'default'; const DB_VERSION = 4; const AUTO_PUSH_DELAY_MS = 1200; +const LAUNCH_QUERY_KEYS = [ + 'intent', + 'entry', + 'list', + 'share-target', + 'share-title', + 'share-text', + 'share-url', +]; const MODE_DESCRIPTIONS = { capture: 'Record and save voice notes.', accomplish: 'Track tasks with checkboxes and reordering.' @@ -357,6 +366,32 @@ function exportAllData() { })); } +async function ensureDefaultListExists() { + const existing = await getList(DEFAULT_LIST_ID); + if (existing) return existing; + + const list = { + id: DEFAULT_LIST_ID, + name: 'My Notes', + mode: 'capture', + createdAt: new Date().toISOString(), + noteOrder: [], + }; + await saveList(list); + return list; +} + +async function getLaunchList(listId) { + const normalizedListId = String(listId || '').trim() || DEFAULT_LIST_ID; + if (normalizedListId === DEFAULT_LIST_ID) { + return ensureDefaultListExists(); + } + + const existing = await getList(normalizedListId); + if (existing) return existing; + return ensureDefaultListExists(); +} + function mergeAllData(snapshot) { return exportAllData().then((localData) => { const merged = mergeSyncData(localData, snapshot); @@ -1056,6 +1091,60 @@ function shouldSubmitTextEntryOnEnter() { return !window.matchMedia('(pointer: coarse)').matches; } +function buildSharedNoteText(payload = {}) { + const title = String(payload.title || '').trim(); + const text = String(payload.text || '').trim(); + const url = String(payload.url || '').trim(); + const parts = []; + + if (title) parts.push(title); + if (text) parts.push(text); + if (url) parts.push(url); + + return parts.join('\n\n').trim(); +} + +function parseLaunchAction(search = window.location.search) { + const params = new URLSearchParams(search); + const hasShareTarget = params.get('share-target') === '1'; + const sharedText = buildSharedNoteText({ + title: params.get('share-title'), + text: params.get('share-text'), + url: params.get('share-url'), + }); + const intent = String(params.get('intent') || '').trim().toLowerCase(); + const entry = params.get('entry') === 'text' ? 'text' : 'voice'; + const entryMode = hasShareTarget ? 'text' : (intent === 'text' ? 'text' : entry); + const listId = String(params.get('list') || '').trim() || DEFAULT_LIST_ID; + const hasLaunchParams = LAUNCH_QUERY_KEYS.some((key) => params.has(key)); + + return { + hasLaunchParams, + listId, + entryMode, + sharedText: hasShareTarget ? sharedText : '', + }; +} + +function clearLaunchQueryParams() { + const url = new URL(window.location.href); + let changed = false; + + for (const key of LAUNCH_QUERY_KEYS) { + if (!url.searchParams.has(key)) continue; + url.searchParams.delete(key); + changed = true; + } + + if (!changed || !window.history || typeof window.history.replaceState !== 'function') { + return; + } + + const search = url.searchParams.toString(); + const nextUrl = `${url.pathname}${search ? `?${search}` : ''}${url.hash}`; + window.history.replaceState({}, '', nextUrl); +} + async function setEditingNote(noteId) { editingNoteId = noteId; await renderListDetail(currentListId); @@ -1175,6 +1264,23 @@ async function createTextNote() { } } +async function saveQuickTextNote(listId, transcription) { + const noteText = normalizeManualNoteText(transcription); + if (!noteText) return false; + + const list = await getLaunchList(listId); + if (!list) return false; + if (!list.noteOrder) { + list.noteOrder = []; + } + + const note = buildNoteRecord(list.id, noteText); + await saveNote(note); + list.noteOrder.unshift(note.id); + await saveList(list); + return true; +} + function createNoteCard(note, list) { const card = document.createElement('div'); card.className = 'note-card'; @@ -2021,6 +2127,22 @@ if ('serviceWorker' in navigator) { .catch((err) => console.error('SW registration failed:', err)); } +async function handleLaunchAction() { + const launchAction = parseLaunchAction(); + if (!launchAction.hasLaunchParams) return; + + clearLaunchQueryParams(); + + const list = await getLaunchList(launchAction.listId); + showListDetailView(list.id); + setEntryMode(launchAction.entryMode); + + if (launchAction.sharedText) { + await saveQuickTextNote(list.id, launchAction.sharedText); + await renderListDetail(list.id); + } +} + // --- Initialization --- showListsView(); @@ -2035,3 +2157,7 @@ if (syncKey) { setCloudMessage(error.message || 'Cloud sync unavailable.', true); }); } + +handleLaunchAction().catch((error) => { + console.error('Launch action failed:', error); +}); diff --git a/index.html b/index.html index 11cc332..c4a025c 100644 --- a/index.html +++ b/index.html @@ -17,7 +17,7 @@

Voice Notes

-

v29

+

v30

diff --git a/public/manifest.json b/public/manifest.json index 8c97390..d0a1a90 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -3,9 +3,40 @@ "short_name": "Voice Notes", "description": "Offline voice notes — record, save, and play back anytime.", "start_url": "./", + "scope": "./", "display": "standalone", "background_color": "#1a1a2e", "theme_color": "#1a1a2e", + "shortcuts": [ + { + "name": "Record Note", + "short_name": "Record", + "description": "Open straight into voice capture.", + "url": "./?intent=record&entry=voice&list=default", + "icons": [ + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" } + ] + }, + { + "name": "Type Note", + "short_name": "Type", + "description": "Open straight into text entry.", + "url": "./?intent=text&entry=text&list=default", + "icons": [ + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" } + ] + } + ], + "share_target": { + "action": "./?share-target=1", + "method": "GET", + "enctype": "application/x-www-form-urlencoded", + "params": { + "title": "share-title", + "text": "share-text", + "url": "share-url" + } + }, "icons": [ { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" } diff --git a/public/sw.js b/public/sw.js index e66eab4..be2b4f8 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'voice-notes-v29'; +const CACHE_NAME = 'voice-notes-v30'; const SHELL = [ './', './app.css', @@ -42,15 +42,16 @@ self.addEventListener('fetch', (e) => { e.respondWith((async () => { const cache = await caches.open(CACHE_NAME); + const isNavigation = e.request.mode === 'navigate'; try { const response = await fetch(e.request); if (response && response.ok) { - cache.put(e.request, response.clone()); + cache.put(isNavigation ? './' : e.request, response.clone()); } return response; } catch (error) { - const cached = await cache.match(e.request); + const cached = await cache.match(isNavigation ? './' : e.request); if (cached) { return cached; } diff --git a/tests.js b/tests.js index b45490a..718996e 100644 --- a/tests.js +++ b/tests.js @@ -137,6 +137,39 @@ function shouldSubmitTextEntryOnEnter(pointerType) { return pointerType !== 'coarse'; } +function buildSharedNoteText(payload = {}) { + const title = String(payload.title || '').trim(); + const text = String(payload.text || '').trim(); + const url = String(payload.url || '').trim(); + const parts = []; + + if (title) parts.push(title); + if (text) parts.push(text); + if (url) parts.push(url); + + return parts.join('\n\n').trim(); +} + +function parseLaunchAction(search) { + const params = new URLSearchParams(search); + const hasShareTarget = params.get('share-target') === '1'; + const sharedText = buildSharedNoteText({ + title: params.get('share-title'), + text: params.get('share-text'), + url: params.get('share-url'), + }); + const intent = String(params.get('intent') || '').trim().toLowerCase(); + const entry = params.get('entry') === 'text' ? 'text' : 'voice'; + + return { + hasLaunchParams: ['intent', 'entry', 'list', 'share-target', 'share-title', 'share-text', 'share-url'] + .some((key) => params.has(key)), + listId: String(params.get('list') || '').trim() || 'default', + entryMode: hasShareTarget ? 'text' : (intent === 'text' ? 'text' : entry), + sharedText: hasShareTarget ? sharedText : '', + }; +} + function buildNoteRecord(listId, transcription, options = {}) { return { id: options.id || 'generated-id', @@ -775,6 +808,23 @@ assertEqual(normalizeManualNoteText('first\r\nsecond'), 'first\nsecond', 'normal assertEqual(normalizeManualNoteText(''), '', 'empty string stays empty'); assertEqual(shouldSubmitTextEntryOnEnter('fine'), true, 'desktop pointer type submits on enter'); assertEqual(shouldSubmitTextEntryOnEnter('coarse'), false, 'coarse pointer type does not depend on enter'); +assertEqual(buildSharedNoteText({ title: 'Article', text: 'Read later', url: 'https://example.com' }), 'Article\n\nRead later\n\nhttps://example.com', 'buildSharedNoteText combines shared fields'); +assertEqual(buildSharedNoteText({ text: 'Quick thought' }), 'Quick thought', 'buildSharedNoteText keeps lone text'); +assertEqual(buildSharedNoteText({}), '', 'buildSharedNoteText returns empty string with no content'); + +{ + const action = parseLaunchAction('?intent=record&entry=voice&list=default'); + assertEqual(action.hasLaunchParams, true, 'parseLaunchAction detects shortcut launch params'); + assertEqual(action.entryMode, 'voice', 'parseLaunchAction keeps voice shortcut mode'); + assertEqual(action.listId, 'default', 'parseLaunchAction keeps requested list id'); + assertEqual(action.sharedText, '', 'parseLaunchAction does not create shared text for shortcuts'); +} + +{ + const action = parseLaunchAction('?share-target=1&share-title=Article&share-text=Read%20later&share-url=https%3A%2F%2Fexample.com'); + assertEqual(action.entryMode, 'text', 'parseLaunchAction forces text mode for shared content'); + assertEqual(action.sharedText, 'Article\n\nRead later\n\nhttps://example.com', 'parseLaunchAction formats shared content'); +} { const note = buildNoteRecord('list-1', 'Draft note', { duration: 12, completed: true }); @@ -1332,6 +1382,7 @@ suite('Source file integrity'); assert(indexHtml.includes('sync-btn'), 'index.html has sync button'); assert(indexHtml.includes('help-modal'), 'index.html has help modal'); assert(indexHtml.includes('sync-modal'), 'index.html has sync modal'); + assert(indexHtml.includes('v30'), 'index.html version is v30'); assert(appCss.includes('.app-modal'), 'app.css defines app modal shell'); assert(appCss.includes('.header-icon-btn'), 'app.css defines header icon buttons'); @@ -1344,6 +1395,10 @@ suite('Source file integrity'); assert(appJs.includes('applyTheme'), 'app.js defines applyTheme'); assert(appJs.includes('scheduleAutoPush'), 'app.js defines auto-sync scheduling'); assert(appJs.includes('voice-notes-theme'), 'app.js uses localStorage key for theme'); + assert(appJs.includes('parseLaunchAction'), 'app.js defines launch action parsing'); + assert(appJs.includes('handleLaunchAction'), 'app.js defines launch action handler'); + assert(appJs.includes('saveQuickTextNote'), 'app.js defines shared-note save helper'); + assert(appJs.includes('buildSharedNoteText'), 'app.js defines shared-note formatter'); } suite('Source file integrity — lists feature'); @@ -1412,11 +1467,19 @@ suite('Source file integrity — lists feature'); assert(indexHtml.includes('back-btn'), 'index.html has back-btn'); assert(indexHtml.includes('new-list-btn'), 'index.html has new-list-btn'); assert(indexHtml.includes('mode-selector'), 'index.html has mode-selector'); - assert(indexHtml.includes('v29'), 'index.html version is v29'); + assert(indexHtml.includes('v30'), 'index.html version is v30'); const swJs = readFileSync(__dirname + '/public/sw.js', 'utf8'); - assert(swJs.includes('voice-notes-v29'), 'sw.js cache version is v29'); + assert(swJs.includes('voice-notes-v30'), 'sw.js cache version is v30'); assert(swJs.includes("url.pathname.startsWith('/api/')"), 'sw.js skips caching api requests'); + assert(swJs.includes("e.request.mode === 'navigate'"), 'sw.js handles navigation requests separately'); + assert(swJs.includes("cache.match(isNavigation ? './' : e.request)"), 'sw.js falls back to cached shell for launch URLs'); + + const manifestJson = readFileSync(__dirname + '/public/manifest.json', 'utf8'); + assert(manifestJson.includes('"shortcuts"'), 'manifest defines app shortcuts'); + assert(manifestJson.includes('"share_target"'), 'manifest defines share target'); + assert(manifestJson.includes('intent=record'), 'manifest includes record shortcut URL'); + assert(manifestJson.includes('intent=text'), 'manifest includes text shortcut URL'); } } // end runTests