Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -2035,3 +2157,7 @@ if (syncKey) {
setCloudMessage(error.message || 'Cloud sync unavailable.', true);
});
}

handleLaunchAction().catch((error) => {
console.error('Launch action failed:', error);
});
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<button id="back-btn" class="hidden" type="button" aria-label="Back">&#8592;</button>
<h1>Voice Notes</h1>
</div>
<p id="app-version">v29</p>
<p id="app-version">v30</p>
<div id="theme-picker">
<button type="button" class="theme-swatch active" data-theme-value="" aria-label="Midnight theme" style="background:#e94560"></button>
<button type="button" class="theme-swatch" data-theme-value="aurora" aria-label="Aurora theme" style="background:linear-gradient(135deg,#f4845f,#f9c74f)"></button>
Expand Down
31 changes: 31 additions & 0 deletions public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use POST share target to avoid truncating shared content

Declaring the share target with "method": "GET" puts the shared payload into the URL query string, which is subject to browser/OS URL-length limits; longer shares (for example selected article text) can be truncated or rejected before parseLaunchAction runs, causing partial or missing notes with no user-visible error. This is user-impacting for common share flows and should use a POST share target path instead.

Useful? React with 👍 / 👎.

"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" }
Expand Down
7 changes: 4 additions & 3 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const CACHE_NAME = 'voice-notes-v29';
const CACHE_NAME = 'voice-notes-v30';
const SHELL = [
'./',
'./app.css',
Expand Down Expand Up @@ -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;
}
Expand Down
67 changes: 65 additions & 2 deletions tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand Down Expand Up @@ -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
Expand Down