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