quickSnote

This commit is contained in:
2026-09-23 13:36:24 -07:00
parent 35c71a1e6d
commit 04a40840d1
14 changed files with 844 additions and 1 deletions

View File

@@ -0,0 +1,57 @@
let serverUrl = '';
browser.storage.sync.get('serverUrl').then(r => {
if (r.serverUrl) serverUrl = r.serverUrl.replace(/\/+$/, '');
});
browser.storage.onChanged.addListener(changes => {
if (changes.serverUrl) {
serverUrl = changes.serverUrl.newValue.replace(/\/+$/, '');
}
});
browser.runtime.onMessage.addListener((msg, sender) => {
switch (msg.kind) {
case 'list_users':
return listUsers();
case 'save_note':
return saveNote(msg);
default:
return Promise.reject(new Error('Unknown message kind'));
}
});
async function request(path, opts) {
if (!serverUrl) throw new Error('Server URL not configured');
opts = opts || {};
opts.credentials = 'include';
const res = await fetch(serverUrl + path, opts);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'HTTP ' + res.status);
}
return res.json();
}
async function listUsers() {
const users = await request('/api/users', { headers: { 'Accept': 'application/json' } });
return { users: users };
}
async function saveNote(msg) {
const body = { body: msg.body };
if (msg.username) body.username = msg.username;
if (msg.id) {
return request('/api/notes/' + msg.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
return request('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}

View File

@@ -0,0 +1,37 @@
(function () {
'use strict';
// ── Page→Extension messaging (used by the snotes helper page) ─────────────
window.addEventListener('message', function (event) {
if (event.source !== window) return;
var msg = event.data;
if (!msg || typeof msg.kind !== 'string') return;
switch (msg.kind) {
case 'snotes-ping':
window.postMessage({ kind: 'snotes-pong', version: '1.0.0' }, '*');
break;
case 'snotes-settings': {
var data = {};
if (msg.serverUrl !== undefined) data.serverUrl = msg.serverUrl;
if (Object.keys(data).length > 0) browser.storage.sync.set(data);
window.postMessage({ kind: 'snotes-settings-ack', ok: true }, '*');
break;
}
}
});
// ── Extension→Page messaging (capture context for the popup) ──────────────
browser.runtime.onMessage.addListener(function (msg) {
if (msg && msg.kind === 'get_context') {
var selection = '';
try { selection = window.getSelection().toString().trim(); } catch (e) {}
return Promise.resolve({
selection: selection,
title: document.title || '',
url: window.location.href || '',
});
}
return Promise.resolve(undefined);
});
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -0,0 +1,55 @@
{
"manifest_version": 3,
"name": "quickSnote",
"version": "1.0.0",
"description": "Quick-capture a note to snotes from any page, with the selected text and page link ready to use",
"permissions": [
"storage"
],
"content_security_policy": {
"extension_pages": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src http://* https://*"
},
"host_permissions": [
"http://*/*",
"https://*/*"
],
"background": {
"scripts": [
"background.js"
]
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
},
"content_scripts": [
{
"matches": [
"*://*/*"
],
"js": [
"content.js"
],
"all_frames": true,
"run_at": "document_idle"
}
],
"browser_specific_settings": {
"gecko": {
"id": "snotes-capture@schmeeve.com",
"strict_min_version": "109.0"
}
},
"action": {
"default_title": "quickSnote",
"default_popup": "popup.html",
"default_icon": {
"48": "icons/icon-48.png",
"96": "icons/icon-96.png"
}
},
"icons": {
"48": "icons/icon-48.png",
"96": "icons/icon-96.png"
}
}

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>quickSnote Options</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1e1e2e;
color: #cdd6f4;
max-width: 520px;
margin: 3rem auto;
padding: 0 1.5rem;
}
h1 { font-size: 1.5rem; margin-bottom: 1.5rem; color: #cba6f7; }
label { display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 0.35rem; color: #a6adc8; }
input[type="text"] {
width: 100%; padding: 0.6rem 0.75rem; border: 1px solid #45475a; border-radius: 6px;
background: #313244; color: #cdd6f4; font-size: 0.9rem; box-sizing: border-box;
}
input[type="text"]:focus { outline: 2px solid #cba6f7; outline-offset: -1px; }
.field { margin-bottom: 1.25rem; }
.hint { font-size: 0.78rem; color: #6c7086; margin-top: 0.25rem; }
#status { margin-top: 1rem; font-size: 0.875rem; color: #a6e3a1; min-height: 1.5rem; }
</style>
</head>
<body>
<h1>quickSnote</h1>
<div class="field">
<label for="serverUrl">snotes server URL</label>
<input type="text" id="serverUrl" placeholder="http://snotes.lan:8000" />
<div class="hint">The address of your snotes app, including port. Auto-filled from the helper page.</div>
</div>
<div class="field">
<button id="save-close" style="padding:0.6rem 1.2rem;background:#cba6f7;color:#1e1e2e;border:none;border-radius:6px;font-size:0.9rem;font-weight:600;cursor:pointer;margin-top:0.5rem">
Save &amp; Close
</button>
</div>
<div id="status"></div>
<script src="options.js"></script>
</body>
</html>

View File

@@ -0,0 +1,32 @@
const urlInput = document.getElementById('serverUrl');
const statusEl = document.getElementById('status');
function showStatus(msg, ok) {
statusEl.textContent = msg;
statusEl.style.color = ok ? '#a6e3a1' : '#f38ba8';
setTimeout(() => { statusEl.textContent = ''; }, 3000);
}
browser.storage.sync.get('serverUrl').then(r => {
if (r.serverUrl) urlInput.value = r.serverUrl;
});
let saveTimer = null;
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(save, 600);
}
async function save() {
const url = urlInput.value.trim().replace(/\/+$/, '');
await browser.storage.sync.set({ serverUrl: url });
showStatus('Saved.', true);
}
document.getElementById('save-close').addEventListener('click', async () => {
await save();
window.close();
});
urlInput.addEventListener('input', scheduleSave);

115
browser-extension/popup.css Normal file
View File

@@ -0,0 +1,115 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1e1e2e;
color: #cdd6f4;
width: 340px;
margin: 0;
padding: 0.75rem;
box-sizing: border-box;
}
.header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.logo {
width: 24px;
height: 24px;
border-radius: 5px;
}
#user {
flex: 1;
padding: 0.35rem 0.5rem;
font-size: 0.82rem;
border: 1px solid #45475a;
border-radius: 6px;
background: #313244;
color: #cdd6f4;
}
textarea {
width: 100%;
height: 150px;
padding: 0.6rem 0.75rem;
border: 1px solid #45475a;
border-radius: 6px;
background: #181825;
color: #cdd6f4;
font-size: 0.88rem;
line-height: 1.45;
resize: vertical;
box-sizing: border-box;
font-family: inherit;
}
textarea:focus {
outline: 2px solid #cba6f7;
outline-offset: -1px;
}
.actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.spacer {
flex: 1;
}
button {
padding: 0.4rem 0.8rem;
font-size: 0.82rem;
font-weight: 600;
border: 1px solid #45475a;
border-radius: 6px;
background: #313244;
color: #cdd6f4;
cursor: pointer;
}
button:hover:not(:disabled) {
background: #45475a;
}
button.primary {
background: #cba6f7;
color: #1e1e2e;
border-color: #cba6f7;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.status {
margin-top: 0.5rem;
font-size: 0.78rem;
min-height: 1.1rem;
}
.status.ok { color: #a6e3a1; }
.status.err { color: #f38ba8; }
.footer {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid #313244;
text-align: right;
}
.footer a {
color: #6c7086;
font-size: 0.72rem;
text-decoration: none;
}
.footer a:hover {
color: #a6adc8;
}

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="header">
<img src="icons/icon-48.png" alt="" class="logo">
<select id="user" title="User / space"></select>
</div>
<textarea id="note" placeholder="Type a note…" spellcheck="false"></textarea>
<div class="actions">
<button id="capture-page" title="Insert page title + link" type="button">⤴ Page</button>
<div class="spacer"></div>
<button id="save" class="primary" type="button">Save</button>
</div>
<div class="status" id="status"></div>
<div class="footer">
<a href="#" id="open-options"><span id="server-indicator"></span></a>
</div>
<script src="popup.js"></script>
</body>
</html>

125
browser-extension/popup.js Normal file
View File

@@ -0,0 +1,125 @@
(function () {
'use strict';
var serverUrl = '';
var noteId = null;
var lastBody = '';
var autosaveTimer = null;
var noteEl = document.getElementById('note');
var userEl = document.getElementById('user');
var saveBtn = document.getElementById('save');
var captureBtn = document.getElementById('capture-page');
var statusEl = document.getElementById('status');
var userOptsBtn = document.getElementById('open-options');
function showStatus(msg, ok) {
statusEl.textContent = msg;
statusEl.className = 'status ' + (ok ? 'ok' : 'err');
if (ok) setTimeout(function () { if (statusEl.textContent === msg) statusEl.textContent = ''; }, 3000);
}
function currentBody() { return noteEl.value; }
async function loadUsers() {
try {
const r = await browser.runtime.sendMessage({ kind: 'list_users' });
const users = (r && r.users) || [];
userEl.innerHTML = '';
for (const u of users) {
const opt = document.createElement('option');
opt.value = u.name;
opt.textContent = u.name;
userEl.appendChild(opt);
}
} catch (e) {
showStatus('Cannot load users: ' + e.message, false);
}
}
async function captureContext() {
try {
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
if (!tabs.length || tabs[0].id == null) return;
const ctx = await browser.tabs.sendMessage(tabs[0].id, { kind: 'get_context' });
if (ctx && ctx.selection && !noteEl.value) {
noteEl.value = ctx.selection;
}
if (ctx) {
noteEl.dataset.pageTitle = ctx.title || '';
noteEl.dataset.pageUrl = ctx.url || '';
}
} catch (e) {
// no selection / content script unavailable on this page — ignore
}
}
function capturePage() {
var title = noteEl.dataset.pageTitle || '';
var url = noteEl.dataset.pageUrl || '';
if (!title && !url) { showStatus('No page to capture', false); return; }
var link = '[' + (title || url) + '](' + (url || '') + ')';
var v = noteEl.value;
if (v && !/\n$/.test(v)) v += '\n';
noteEl.value = v + link + '\n';
noteEl.focus();
scheduleAutosave();
}
async function save() {
var body = currentBody();
if (!body.trim()) { showStatus('Nothing to save', false); return; }
if (body === lastBody && noteId) return;
saveBtn.disabled = true;
try {
const note = await browser.runtime.sendMessage({
kind: 'save_note',
id: noteId,
body: body,
username: userEl.value,
});
noteId = note.id;
lastBody = body;
var tags = (note.tags || []).map(function (t) { return '#' + t; }).join(' ');
showStatus('Saved' + (tags ? ' · ' + tags : ''), true);
} catch (e) {
showStatus('Error: ' + e.message, false);
} finally {
saveBtn.disabled = false;
}
}
function scheduleAutosave() {
if (autosaveTimer) clearTimeout(autosaveTimer);
autosaveTimer = setTimeout(function () {
autosaveTimer = null;
save();
}, 8000);
}
noteEl.addEventListener('input', scheduleAutosave);
saveBtn.addEventListener('click', function () {
if (autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; }
save();
});
captureBtn.addEventListener('click', function () {
if (autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; }
capturePage();
});
userOptsBtn.addEventListener('click', function (e) {
e.preventDefault();
browser.runtime.openOptionsPage();
});
browser.storage.sync.get('serverUrl').then(function (r) {
serverUrl = (r.serverUrl || '').replace(/\/+$/, '');
if (!serverUrl) showStatus('Set the server URL in Options', false);
});
loadUsers();
captureContext();
noteEl.focus();
})();

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# release-firefox.sh — Package + optionally sign the quickSnote extension.
#
# Usage:
# ./release-firefox.sh # Package .zip only
# ./release-firefox.sh --sign # Package + sign via web-ext (auto-bumps patch version)
# ./release-firefox.sh --sign --no-bump # Sign without bumping version
# ./release-firefox.sh --bump # Bump version only, no signing
# ./release-firefox.sh --channel=beta # Sign on beta channel
#
# Prerequisites for --sign:
# - npm install -g web-ext
# - AMO API keys set in environment:
# export AMO_JWT_ISSUER="user:12345678"
# export AMO_JWT_SECRET="..."
#
# The signed .xpi is copied to static/snotes-capture-firefox.xpi so the snotes
# helper page can serve it for one-click install.
set -euo pipefail
SRC="$(cd "$(dirname "$0")" && pwd)"
REPO="$(cd "$SRC"/.. && pwd)"
DIST="$REPO/dist"
STATIC="$REPO/static"
OUT_ZIP="$DIST/snotes-capture-firefox.zip"
OUT_XPI="$STATIC/snotes-capture-firefox.xpi"
MANIFEST="$SRC/manifest.json"
SIGN=false
CHANNEL="unlisted"
BUMP=""
for arg in "$@"; do
case "$arg" in
--sign) SIGN=true ;;
--channel=*) CHANNEL="${arg#*=}" ;;
--bump) BUMP=true ;;
--no-bump) BUMP=false ;;
esac
done
# Auto-bump patch version when signing (AMO rejects duplicate versions)
if [ "$SIGN" = true ] && [ "$BUMP" != false ]; then
BUMP=true
fi
if [ "$BUMP" = true ]; then
CURRENT_VERSION="$(python3 -c "import json,sys; print(json.load(open('$MANIFEST'))['version'])")"
NEW_VERSION="$(python3 -c "
v = '$CURRENT_VERSION'.split('.')
v[-1] = str(int(v[-1]) + 1)
print('.'.join(v))
")"
python3 -c "
import json
with open('$MANIFEST') as f:
m = json.load(f)
m['version'] = '$NEW_VERSION'
with open('$MANIFEST', 'w') as f:
json.dump(m, f, indent=2)
f.write('\n')
"
echo "==> Bumped version: $CURRENT_VERSION -> $NEW_VERSION"
fi
rm -f "$OUT_ZIP"
mkdir -p "$DIST"
echo "==> Packaging Firefox extension..."
cd "$SRC"
zip -q -r "$OUT_ZIP" \
manifest.json \
background.js \
content.js \
popup.html \
popup.js \
popup.css \
options.html \
options.js \
icons/icon-48.png \
icons/icon-96.png
echo " Created: $OUT_ZIP"
echo " Size: $(du -h "$OUT_ZIP" | cut -f1)"
if [ "$SIGN" = true ]; then
if ! command -v web-ext &>/dev/null; then
echo "!! web-ext not found. Install with: npm install -g web-ext"
exit 1
fi
if [ -z "${AMO_JWT_ISSUER:-}" ] || [ -z "${AMO_JWT_SECRET:-}" ]; then
echo "!! AMO_JWT_ISSUER and AMO_JWT_SECRET must be set for signing."
echo " Get keys at: https://addons.mozilla.org/en-US/developers/addon/api/key/"
exit 1
fi
echo "==> Signing via addons.mozilla.org (channel: $CHANNEL)..."
web-ext sign \
--source-dir "$SRC" \
--artifacts-dir "$DIST" \
--api-key "$AMO_JWT_ISSUER" \
--api-secret "$AMO_JWT_SECRET" \
--channel "$CHANNEL" \
--ignore-files "*.sh"
echo " Signed .xpi in: $DIST/"
SIGNED_XPI="$(ls -t "$DIST"/*.xpi 2>/dev/null | head -1)"
if [ -n "$SIGNED_XPI" ]; then
cp "$SIGNED_XPI" "$OUT_XPI"
echo " Copied to: $OUT_XPI"
fi
fi
echo "==> Done."