From a9fb7a591126a672f5b49e2d257419e365729b1c Mon Sep 17 00:00:00 2001 From: JohnsonKC201 Date: Fri, 9 Oct 2026 00:47:57 -0400 Subject: [PATCH 1/3] feat(tools): snippets, text you save once and get back by name Copy an address, a sign-off or a meeting link, type save sig, and from then on ;sig copies it again. ; lists them, save sig replaces one, forget sig deletes one. Thirty at most, 2,000 characters each, a name is one word. Saving reuses the text fixers' rule: the launcher sends a name, and main reads the clipboard itself when the row is run. The pet never says the text back, and a copy made from a snippet is flagged as ours so clipboard history does not record it. Snippets live at the top level of the config, like to-dos. Settings saves the whole tools object from its own copy, so a snippet saved while Settings was open would have been overwritten there. They are plain text on disk, so text in a known key or token format is refused. The check is the pattern list only: the broader guess used for clipboard history turns away a mixed-case email address and a meeting link, which are what people save. Several words after save or forget are left to the web search. The ? list trades its unit conversion row for a snippet row, since it holds eight; conversions are now named on the calculator row. --- src/config.js | 6 ++ src/launcher-icons.js | 1 + src/launcher.html | 5 +- src/locales/de.json | 24 +++++- src/locales/en.json | 24 +++++- src/locales/es.json | 24 +++++- src/locales/fr.json | 24 +++++- src/locales/hi.json | 24 +++++- src/locales/ja.json | 24 +++++- src/locales/pt-BR.json | 24 +++++- src/locales/zh-CN.json | 24 +++++- src/tools/actions.js | 23 ++++++ src/tools/commands.js | 46 ++++++++++-- src/tools/index.js | 5 +- src/tools/snippets.js | 61 ++++++++++++++++ tests/commands.test.js | 4 +- tests/i18n.test.js | 5 +- tests/launcher-wiring.test.js | 4 +- tests/snippets.test.js | 133 ++++++++++++++++++++++++++++++++++ 19 files changed, 454 insertions(+), 31 deletions(-) create mode 100644 src/tools/snippets.js create mode 100644 tests/snippets.test.js diff --git a/src/config.js b/src/config.js index 0023ee0..fe52db3 100644 --- a/src/config.js +++ b/src/config.js @@ -13,6 +13,7 @@ const { isSpecies, coatsFor, defaultCoatIndex } = require('./pets'); const { MAX_THEMES } = require('./themes'); const { normalizeShortcuts } = require('./tools/shortcuts'); const { normalizeTodos } = require('./tools/todos'); +const { normalizeSnippets } = require('./tools/snippets'); const { isSupported: isLanguage, AUTO: AUTO_LANGUAGE } = require('./i18n'); // The settings file format. Bump it when a change needs more than normalize() @@ -83,6 +84,10 @@ const DEFAULTS = { shortcuts: [], // [{ id, label, target }] validated by tools/shortcuts.js }, todos: { day: '', items: [], nudged: '' }, // today's list, see tools/todos.js + // Saved text, copied back with ";name" in the launcher: [{ name, text }]. Top + // level, like todos, because Settings saves the whole `tools` object from its + // own copy and would overwrite a snippet saved while it was open. + snippets: [], }; // The launcher hotkeys offered in Settings. A fixed list rather than free text, so @@ -212,6 +217,7 @@ function normalize(cfg) { }; })(), todos: normalizeTodos(c.todos), + snippets: normalizeSnippets(c.snippets), reminders: reminders.reduce((out, r) => { if (!r || typeof r !== 'object') return out; const hhmm = String(r.hhmm || ''); diff --git a/src/launcher-icons.js b/src/launcher-icons.js index 65aac20..7163c16 100644 --- a/src/launcher-icons.js +++ b/src/launcher-icons.js @@ -29,6 +29,7 @@ file: [P('M6 3h8l5 5v13H6z'), P('M14 3v5h5')], app: [R(4, 4, 7, 7), R(13, 4, 7, 7), R(4, 13, 7, 7), R(13, 13, 7, 7)], text: [P('M5 7V5h14v2'), P('M12 5v14'), P('M9 19h6')], + snippet: [P('M6 3h12v18l-6-4-6 4z'), P('M9.5 8h5'), P('M9.5 11.5h3')], info: [C(12, 12, 9), P('M12 11v5'), P('M12 8h.01')], }; diff --git a/src/launcher.html b/src/launcher.html index c61f4c5..1adac54 100644 --- a/src/launcher.html +++ b/src/launcher.html @@ -22,7 +22,7 @@ --kbd: rgba(255, 255, 255, .08); --accent: #e8943c; --accent-soft: rgba(232, 148, 60, .18); --c-link: #6aa9ff; --c-folder: #f0b54a; --c-app: #b58cff; --c-file: #9fb3c8; --c-mail: #ff8a80; - --c-calc: #7fd08a; --c-time: #d49bff; --c-todo: #e8943c; --c-note: #f2cf5b; --c-sys: #c3c8d4; --c-search: #6aa9ff; --c-text: #5fc9c0; + --c-calc: #7fd08a; --c-time: #d49bff; --c-todo: #e8943c; --c-note: #f2cf5b; --c-sys: #c3c8d4; --c-search: #6aa9ff; --c-text: #5fc9c0; --c-snippet: #f08fb0; --shadow: 0 18px 50px rgba(0, 0, 0, .45); } @media (prefers-color-scheme: light) { @@ -38,7 +38,7 @@ --kbd: rgba(0, 0, 0, .06); --accent-soft: rgba(232, 148, 60, .22); --c-link: #2f74e0; --c-folder: #c98a12; --c-app: #7b4fd6; --c-file: #5d7288; --c-mail: #d4473b; - --c-calc: #2f9447; --c-time: #9a4fd6; --c-todo: #c9721f; --c-note: #b08a0e; --c-sys: #5a6070; --c-search: #2f74e0; --c-text: #1f8f88; + --c-calc: #2f9447; --c-time: #9a4fd6; --c-todo: #c9721f; --c-note: #b08a0e; --c-sys: #5a6070; --c-search: #2f74e0; --c-text: #1f8f88; --c-snippet: #c2456f; --shadow: 0 18px 50px rgba(0, 0, 0, .18); } } @@ -89,6 +89,7 @@ .row[data-icon="timer"] { --k: var(--c-time); } .row[data-icon="todo"] { --k: var(--c-todo); } .row[data-icon="note"], .row[data-icon="notes"] { --k: var(--c-note); } .row[data-icon="text"] { --k: var(--c-text); } + .row[data-icon="snippet"] { --k: var(--c-snippet); } .txt { min-width: 0; } .t { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } diff --git a/src/locales/de.json b/src/locales/de.json index 883309e..21755ce 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -40,14 +40,15 @@ "cmd.help.title": "Was kann ich eingeben?", "cmd.help.sub": "Beispiele zum Ausprobieren", "cmd.help.keys": "hilfe beispiele befehle wie tipps", - "cmd.help.calc.sub": "Rechne etwas aus. Enter kopiert das Ergebnis", - "cmd.help.convert.sub": "Rechnet Längen, Gewichte, Temperaturen und Dateigrößen um", + "cmd.help.calc.sub": "Rechnen und umrechnen, etwa 5 km in mi. Enter kopiert das Ergebnis", "cmd.help.timer.ex": "10m Tee", "cmd.help.timer.sub": "Startet einen Timer. Auch 1h30m oder timer 90s", "cmd.help.todo.ex": "todo Zahnarzt anrufen", "cmd.help.todo.sub": "Kommt auf die Liste für heute. \"done 1\" hakt die erste ab", "cmd.help.note.ex": "note der WLAN-Code hängt am Kühlschrank", "cmd.help.note.sub": "Speichert eine Zeile. \"open notes\" öffnet sie", + "cmd.help.snippet.ex": "save gruss", + "cmd.help.snippet.sub": "Merkt sich den kopierten Text. Mit ;gruss holst du ihn zurück", "cmd.help.search.ex": "g beste Pizza in der Nähe", "cmd.help.search.sub": "Sucht im Web. Auch ddg oder b", "cmd.help.text.sub": "Kopierten Text richten: upper, lower, title, plain, one line, count", @@ -85,6 +86,7 @@ "hint.done": "Erledigen", "hint.undo": "Rückgängig", "hint.try": "Probieren", + "hint.forget": "Vergessen", "calc.sub": "Enter kopiert das Ergebnis", "calc.bad.title": "Das kann ich nicht ausrechnen", @@ -96,6 +98,17 @@ "note.saveTitle": "Als Notiz speichern: {text}", "note.sub": "Fügt deiner Notizdatei eine Zeile hinzu", "clip.sub": "Erneut kopieren", + "snippet.none.title": "Noch keine Textbausteine", + "snippet.none.sub": "Kopier einen Text und tippe save und einen Namen, etwa save gruss", + "snippet.save.title": "Kopierten Text als ;{name} speichern", + "snippet.save.replace": ";{name} durch den kopierten Text ersetzen", + "snippet.save.sub": "Tippe jederzeit ;{name}, um ihn wieder zu kopieren", + "snippet.badName.title": "Der Name eines Textbausteins ist ein Wort", + "snippet.badName.sub": "Buchstaben und Ziffern, höchstens 24, etwa gruss oder adresse", + "snippet.forget.title": ";{name} vergessen", + "snippet.forget.sub": "Löscht den gespeicherten Text", + "snippet.missing.title": ";{name} gibt es nicht", + "snippet.missing.sub": "Tippe ;, um deine Textbausteine zu sehen", "todo.item.sub": "Aufgabe {n}", "todo.item.subDone": "Aufgabe {n}, erledigt", @@ -132,6 +145,13 @@ "say.text.none": "Kopier erst etwas Text und frag mich dann noch mal.", "say.text.tooBig": "Das ist zu viel Text für mich.", "say.text.count": "Wörter: {words}. Zeichen: {chars}. Zeilen: {lines}.", + "say.snippet.copied": ";{name} kopiert. Füg es ein, wo du willst.", + "say.snippet.saved": "Gespeichert. Mit ;{name} holst du es zurück.", + "say.snippet.forgot": ";{name} ist vergessen.", + "say.snippet.gone": "Diesen Textbaustein gibt es nicht mehr.", + "say.snippet.secret": "Das sieht nach einem Schlüssel oder Token aus, das merke ich mir nicht.", + "say.snippet.tooBig": "Das ist zu lang für einen Textbaustein.", + "say.snippet.full": "Bei {max} Textbausteinen ist Schluss. Vergiss erst einen.", "say.awake.on": "Ich halte den Bildschirm wach. Schalte es im Infobereich oder hier wieder aus.", "say.awake.off": "Der Bildschirm darf wieder schlafen.", "say.clipboard.on": "Zwischenablage-Verlauf an. Nur im Arbeitsspeicher, nie gespeichert.", diff --git a/src/locales/en.json b/src/locales/en.json index d8f5687..b33aa24 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -40,14 +40,15 @@ "cmd.help.title": "What can I type?", "cmd.help.sub": "Examples you can try", "cmd.help.keys": "help examples commands how tips", - "cmd.help.calc.sub": "Do a sum. Enter copies the answer", - "cmd.help.convert.sub": "Convert length, weight, temperature and file sizes", + "cmd.help.calc.sub": "Sums and conversions, like 5 km in mi. Enter copies the answer", "cmd.help.timer.ex": "10m tea", "cmd.help.timer.sub": "Start a timer. Also 1h30m or timer 90s", "cmd.help.todo.ex": "todo call the dentist", "cmd.help.todo.sub": "Add to today's list. \"done 1\" ticks the first one", "cmd.help.note.ex": "note the wifi code is on the fridge", "cmd.help.note.sub": "Save a line. \"open notes\" reads them back", + "cmd.help.snippet.ex": "save sig", + "cmd.help.snippet.sub": "Keeps the text you copied. Type ;sig to get it back", "cmd.help.search.ex": "g best pizza near me", "cmd.help.search.sub": "Search the web. Also ddg or b", "cmd.help.text.sub": "Fix text you copied: upper, lower, title, plain, one line, count", @@ -85,6 +86,7 @@ "hint.done": "Done", "hint.undo": "Undo", "hint.try": "Try", + "hint.forget": "Forget", "calc.sub": "Enter copies the answer", "calc.bad.title": "Not a sum I can do", @@ -96,6 +98,17 @@ "note.saveTitle": "Save as a note: {text}", "note.sub": "Adds a line to your notes file", "clip.sub": "Copy again", + "snippet.none.title": "No snippets yet", + "snippet.none.sub": "Copy some text, then type save and a name, like save sig", + "snippet.save.title": "Save the copied text as ;{name}", + "snippet.save.replace": "Replace ;{name} with the copied text", + "snippet.save.sub": "Type ;{name} any time to copy it again", + "snippet.badName.title": "A snippet name is one word", + "snippet.badName.sub": "Letters and numbers, up to 24, like sig or addr", + "snippet.forget.title": "Forget ;{name}", + "snippet.forget.sub": "Deletes the saved text", + "snippet.missing.title": "There is no ;{name}", + "snippet.missing.sub": "Type ; to see your snippets", "todo.item.sub": "To-do {n}", "todo.item.subDone": "To-do {n}, done", @@ -132,6 +145,13 @@ "say.text.none": "Copy some text first, then ask me again.", "say.text.tooBig": "That is too much text for me to handle.", "say.text.count": "Words: {words}. Characters: {chars}. Lines: {lines}.", + "say.snippet.copied": "Copied ;{name}. Paste it where you want.", + "say.snippet.saved": "Saved. Type ;{name} to get it back.", + "say.snippet.forgot": "Forgot ;{name}.", + "say.snippet.gone": "That snippet is gone.", + "say.snippet.secret": "That looks like a key or a token, so I will not keep it.", + "say.snippet.tooBig": "That is too long for a snippet.", + "say.snippet.full": "{max} snippets is my limit. Forget one first.", "say.awake.on": "Keeping the screen awake. Turn it off from the tray or here.", "say.awake.off": "The screen can sleep again.", "say.clipboard.on": "Clipboard history on. Kept in memory only, never saved.", diff --git a/src/locales/es.json b/src/locales/es.json index 3e13f19..021e89f 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -40,14 +40,15 @@ "cmd.help.title": "¿Qué puedo escribir?", "cmd.help.sub": "Ejemplos para probar", "cmd.help.keys": "ayuda ejemplos comandos cómo consejos", - "cmd.help.calc.sub": "Haz una cuenta. Intro copia el resultado", - "cmd.help.convert.sub": "Convierte longitud, peso, temperatura y tamaños de archivo", + "cmd.help.calc.sub": "Cuentas y conversiones, como 5 km in mi. Intro copia el resultado", "cmd.help.timer.ex": "10m té", "cmd.help.timer.sub": "Inicia un temporizador. También 1h30m o timer 90s", "cmd.help.todo.ex": "todo llamar al dentista", "cmd.help.todo.sub": "Añade a la lista de hoy. \"done 1\" marca la primera", "cmd.help.note.ex": "note la clave del wifi está en la nevera", "cmd.help.note.sub": "Guarda una línea. \"open notes\" las abre", + "cmd.help.snippet.ex": "save firma", + "cmd.help.snippet.sub": "Guarda el texto que copiaste. Escribe ;firma para recuperarlo", "cmd.help.search.ex": "g mejor pizza cerca de mí", "cmd.help.search.sub": "Busca en la web. También ddg o b", "cmd.help.text.sub": "Arregla el texto copiado: upper, lower, title, plain, one line, count", @@ -85,6 +86,7 @@ "hint.done": "Hecho", "hint.undo": "Deshacer", "hint.try": "Probar", + "hint.forget": "Olvidar", "calc.sub": "Intro copia el resultado", "calc.bad.title": "Esa cuenta no la sé hacer", @@ -96,6 +98,17 @@ "note.saveTitle": "Guardar como nota: {text}", "note.sub": "Añade una línea a tu archivo de notas", "clip.sub": "Copiar otra vez", + "snippet.none.title": "Aún no hay fragmentos", + "snippet.none.sub": "Copia un texto y escribe save y un nombre, como save firma", + "snippet.save.title": "Guardar el texto copiado como ;{name}", + "snippet.save.replace": "Reemplazar ;{name} por el texto copiado", + "snippet.save.sub": "Escribe ;{name} cuando quieras para copiarlo otra vez", + "snippet.badName.title": "El nombre de un fragmento es una sola palabra", + "snippet.badName.sub": "Letras y números, hasta 24, como firma o casa", + "snippet.forget.title": "Olvidar ;{name}", + "snippet.forget.sub": "Borra el texto guardado", + "snippet.missing.title": "No existe ;{name}", + "snippet.missing.sub": "Escribe ; para ver tus fragmentos", "todo.item.sub": "Tarea {n}", "todo.item.subDone": "Tarea {n}, hecha", @@ -132,6 +145,13 @@ "say.text.none": "Copia primero un texto y vuelve a pedírmelo.", "say.text.tooBig": "Es demasiado texto para mí.", "say.text.count": "Palabras: {words}. Caracteres: {chars}. Líneas: {lines}.", + "say.snippet.copied": "Copiado ;{name}. Pégalo donde quieras.", + "say.snippet.saved": "Guardado. Escribe ;{name} para recuperarlo.", + "say.snippet.forgot": "Olvidé ;{name}.", + "say.snippet.gone": "Ese fragmento ya no existe.", + "say.snippet.secret": "Eso parece una clave o un token, así que no lo guardo.", + "say.snippet.tooBig": "Es demasiado largo para un fragmento.", + "say.snippet.full": "Mi límite son {max} fragmentos. Olvida uno primero.", "say.awake.on": "Mantengo la pantalla encendida. Desactívalo desde la bandeja o desde aquí.", "say.awake.off": "La pantalla ya puede apagarse.", "say.clipboard.on": "Historial del portapapeles activado. Solo en memoria, nunca se guarda.", diff --git a/src/locales/fr.json b/src/locales/fr.json index 53e61e9..3451132 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -40,14 +40,15 @@ "cmd.help.title": "Que puis-je taper ?", "cmd.help.sub": "Des exemples à essayer", "cmd.help.keys": "aide exemples commandes comment astuces", - "cmd.help.calc.sub": "Faites un calcul. Entrée copie le résultat", - "cmd.help.convert.sub": "Convertit longueurs, poids, températures et tailles de fichier", + "cmd.help.calc.sub": "Calculs et conversions, comme 5 km in mi. Entrée copie le résultat", "cmd.help.timer.ex": "10m thé", "cmd.help.timer.sub": "Lance un minuteur. Aussi 1h30m ou timer 90s", "cmd.help.todo.ex": "todo appeler le dentiste", "cmd.help.todo.sub": "Ajoute à la liste du jour. \"done 1\" coche la première", "cmd.help.note.ex": "note le code wifi est sur le frigo", "cmd.help.note.sub": "Enregistre une ligne. \"open notes\" les ouvre", + "cmd.help.snippet.ex": "save signature", + "cmd.help.snippet.sub": "Garde le texte copié. Tapez ;signature pour le retrouver", "cmd.help.search.ex": "g meilleure pizza près de chez moi", "cmd.help.search.sub": "Cherche sur le web. Aussi ddg ou b", "cmd.help.text.sub": "Corrige le texte copié : upper, lower, title, plain, one line, count", @@ -85,6 +86,7 @@ "hint.done": "Terminer", "hint.undo": "Rouvrir", "hint.try": "Essayer", + "hint.forget": "Oublier", "calc.sub": "Entrée copie le résultat", "calc.bad.title": "Je ne sais pas faire ce calcul", @@ -96,6 +98,17 @@ "note.saveTitle": "Enregistrer comme note : {text}", "note.sub": "Ajoute une ligne à votre fichier de notes", "clip.sub": "Copier à nouveau", + "snippet.none.title": "Pas encore de texte enregistré", + "snippet.none.sub": "Copiez du texte, puis tapez save et un nom, comme save signature", + "snippet.save.title": "Enregistrer le texte copié sous ;{name}", + "snippet.save.replace": "Remplacer ;{name} par le texte copié", + "snippet.save.sub": "Tapez ;{name} quand vous voulez pour le copier à nouveau", + "snippet.badName.title": "Le nom tient en un seul mot", + "snippet.badName.sub": "Lettres et chiffres, 24 au plus, comme signature ou adresse", + "snippet.forget.title": "Oublier ;{name}", + "snippet.forget.sub": "Supprime le texte enregistré", + "snippet.missing.title": "Il n'y a pas de ;{name}", + "snippet.missing.sub": "Tapez ; pour voir vos textes enregistrés", "todo.item.sub": "Tâche {n}", "todo.item.subDone": "Tâche {n}, terminée", @@ -132,6 +145,13 @@ "say.text.none": "Copiez d'abord du texte, puis redemandez-moi.", "say.text.tooBig": "C'est trop de texte pour moi.", "say.text.count": "Mots : {words}. Caractères : {chars}. Lignes : {lines}.", + "say.snippet.copied": ";{name} est copié. Collez-le où vous voulez.", + "say.snippet.saved": "Enregistré. Tapez ;{name} pour le retrouver.", + "say.snippet.forgot": ";{name} est oublié.", + "say.snippet.gone": "Ce texte n'existe plus.", + "say.snippet.secret": "Cela ressemble à une clé ou à un jeton, je ne le garde pas.", + "say.snippet.tooBig": "C'est trop long pour être enregistré.", + "say.snippet.full": "Ma limite est de {max} textes. Oubliez-en un d'abord.", "say.awake.on": "Je garde l'écran allumé. Pour arrêter, passez par la zone de notification ou ici.", "say.awake.off": "L'écran peut de nouveau se mettre en veille.", "say.clipboard.on": "Historique du presse-papiers activé. Gardé en mémoire seulement, jamais enregistré.", diff --git a/src/locales/hi.json b/src/locales/hi.json index b8487b2..aca6189 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -40,14 +40,15 @@ "cmd.help.title": "मैं क्या टाइप कर सकता हूँ?", "cmd.help.sub": "आज़माने के लिए उदाहरण", "cmd.help.keys": "मदद सहायता उदाहरण कमांड कैसे", - "cmd.help.calc.sub": "हिसाब लगाएँ। Enter दबाने पर उत्तर कॉपी होगा", - "cmd.help.convert.sub": "लंबाई, वज़न, तापमान और फ़ाइल साइज़ बदलें", + "cmd.help.calc.sub": "हिसाब और रूपांतरण, जैसे 5 km in mi। Enter दबाने पर उत्तर कॉपी होगा", "cmd.help.timer.ex": "10m चाय", "cmd.help.timer.sub": "टाइमर शुरू करें। 1h30m या timer 90s भी चलेगा", "cmd.help.todo.ex": "todo डेंटिस्ट को फ़ोन करना", "cmd.help.todo.sub": "आज की सूची में जोड़ें। \"done 1\" पहला काम पूरा करता है", "cmd.help.note.ex": "note वाईफ़ाई का कोड फ़्रिज पर है", "cmd.help.note.sub": "एक पंक्ति सेव करें। \"open notes\" से नोट्स खुलते हैं", + "cmd.help.snippet.ex": "save sig", + "cmd.help.snippet.sub": "कॉपी किया टेक्स्ट याद रखता है। वापस पाने के लिए ;sig टाइप करें", "cmd.help.search.ex": "g पास में सबसे अच्छा पिज़्ज़ा", "cmd.help.search.sub": "वेब पर खोजें। ddg या b भी चलेगा", "cmd.help.text.sub": "कॉपी किया टेक्स्ट ठीक करें: upper, lower, title, plain, one line, count", @@ -85,6 +86,7 @@ "hint.done": "पूरा", "hint.undo": "वापस लें", "hint.try": "आज़माएँ", + "hint.forget": "भूल जाएँ", "calc.sub": "Enter दबाने पर उत्तर कॉपी होगा", "calc.bad.title": "यह हिसाब मुझसे नहीं होगा", @@ -96,6 +98,17 @@ "note.saveTitle": "नोट के रूप में सेव करें: {text}", "note.sub": "आपकी नोट्स फ़ाइल में एक पंक्ति जोड़ता है", "clip.sub": "फिर से कॉपी करें", + "snippet.none.title": "अभी कोई स्निपेट नहीं है", + "snippet.none.sub": "कुछ टेक्स्ट कॉपी करें, फिर save और एक नाम टाइप करें, जैसे save sig", + "snippet.save.title": "कॉपी किया टेक्स्ट ;{name} नाम से सेव करें", + "snippet.save.replace": ";{name} को कॉपी किए टेक्स्ट से बदलें", + "snippet.save.sub": "फिर से कॉपी करने के लिए कभी भी ;{name} टाइप करें", + "snippet.badName.title": "स्निपेट का नाम एक ही शब्द होता है", + "snippet.badName.sub": "अक्षर और अंक, ज़्यादा से ज़्यादा 24, जैसे sig या addr", + "snippet.forget.title": ";{name} भूल जाएँ", + "snippet.forget.sub": "सेव किया टेक्स्ट हटा देता है", + "snippet.missing.title": ";{name} नाम का कोई स्निपेट नहीं है", + "snippet.missing.sub": "अपने स्निपेट देखने के लिए ; टाइप करें", "todo.item.sub": "काम {n}", "todo.item.subDone": "काम {n}, पूरा", @@ -132,6 +145,13 @@ "say.text.none": "पहले कुछ टेक्स्ट कॉपी करें, फिर मुझसे कहें।", "say.text.tooBig": "यह टेक्स्ट मेरे लिए बहुत ज़्यादा है।", "say.text.count": "शब्द: {words}। अक्षर: {chars}। पंक्तियाँ: {lines}।", + "say.snippet.copied": ";{name} कॉपी हो गया। जहाँ चाहें पेस्ट करें।", + "say.snippet.saved": "सेव हो गया। वापस पाने के लिए ;{name} टाइप करें।", + "say.snippet.forgot": ";{name} भुला दिया।", + "say.snippet.gone": "वह स्निपेट अब नहीं है।", + "say.snippet.secret": "यह किसी कुंजी या टोकन जैसा लगता है, इसलिए मैं इसे नहीं रखूँगा।", + "say.snippet.tooBig": "यह स्निपेट के लिए बहुत लंबा है।", + "say.snippet.full": "मेरी सीमा {max} स्निपेट है। पहले एक भुला दें।", "say.awake.on": "स्क्रीन चालू रहेगी। इसे ट्रे से या यहीं से बंद करें।", "say.awake.off": "अब स्क्रीन फिर से सो सकती है।", "say.clipboard.on": "क्लिपबोर्ड इतिहास चालू। सिर्फ़ मेमोरी में, कभी सेव नहीं होता।", diff --git a/src/locales/ja.json b/src/locales/ja.json index d90fc8a..50b0eb0 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -40,14 +40,15 @@ "cmd.help.title": "何を入力できますか?", "cmd.help.sub": "試せる入力例", "cmd.help.keys": "ヘルプ 使い方 例 コマンド ヒント", - "cmd.help.calc.sub": "計算します。Enter で答えをコピー", - "cmd.help.convert.sub": "長さ、重さ、温度、ファイルサイズを換算します", + "cmd.help.calc.sub": "計算と換算ができます (例: 5 km in mi)。Enter で答えをコピー", "cmd.help.timer.ex": "10m お茶", "cmd.help.timer.sub": "タイマーを開始します。1h30m や timer 90s も使えます", "cmd.help.todo.ex": "todo 歯医者に電話する", "cmd.help.todo.sub": "今日のリストに追加します。\"done 1\" で 1 件目を完了", "cmd.help.note.ex": "note Wi-Fi のパスワードは冷蔵庫に貼ってある", "cmd.help.note.sub": "1 行保存します。\"open notes\" でメモを開きます", + "cmd.help.snippet.ex": "save sig", + "cmd.help.snippet.sub": "コピーした文字を覚えます。;sig と入力すると取り出せます", "cmd.help.search.ex": "g 近くのおいしいピザ", "cmd.help.search.sub": "ウェブを検索します。ddg や b も使えます", "cmd.help.text.sub": "コピーした文字を整えます: upper、lower、title、plain、one line、count", @@ -85,6 +86,7 @@ "hint.done": "完了", "hint.undo": "元に戻す", "hint.try": "試す", + "hint.forget": "削除", "calc.sub": "Enter で答えをコピー", "calc.bad.title": "その計算はできません", @@ -96,6 +98,17 @@ "note.saveTitle": "メモとして保存: {text}", "note.sub": "メモファイルに 1 行追加します", "clip.sub": "もう一度コピー", + "snippet.none.title": "定型文はまだありません", + "snippet.none.sub": "文字をコピーしてから、save と名前を入力します (例: save sig)", + "snippet.save.title": "コピーした文字を ;{name} として保存", + "snippet.save.replace": ";{name} をコピーした文字で置き換える", + "snippet.save.sub": ";{name} と入力すればいつでもコピーできます", + "snippet.badName.title": "定型文の名前は 1 語です", + "snippet.badName.sub": "文字と数字で 24 文字まで (例: sig、addr)", + "snippet.forget.title": ";{name} を削除", + "snippet.forget.sub": "保存した文字を消します", + "snippet.missing.title": ";{name} はありません", + "snippet.missing.sub": "; と入力すると定型文の一覧が出ます", "todo.item.sub": "やること {n}", "todo.item.subDone": "やること {n}、完了", @@ -132,6 +145,13 @@ "say.text.none": "先に文字をコピーしてから、もう一度どうぞ。", "say.text.tooBig": "文字が多すぎて扱えません。", "say.text.count": "単語: {words}。文字: {chars}。行: {lines}。", + "say.snippet.copied": ";{name} をコピーしました。好きな場所に貼り付けてください。", + "say.snippet.saved": "保存しました。;{name} と入力すると取り出せます。", + "say.snippet.forgot": ";{name} を削除しました。", + "say.snippet.gone": "その定型文はもうありません。", + "say.snippet.secret": "キーやトークンのようなので、保存しません。", + "say.snippet.tooBig": "定型文にするには長すぎます。", + "say.snippet.full": "定型文は {max} 件までです。先に 1 件削除してください。", "say.awake.on": "画面をスリープさせません。トレイかここからオフにできます。", "say.awake.off": "画面はまたスリープできます。", "say.clipboard.on": "クリップボード履歴をオンにしました。メモリ上だけに保持し、保存しません。", diff --git a/src/locales/pt-BR.json b/src/locales/pt-BR.json index 91a5d56..a9682c6 100644 --- a/src/locales/pt-BR.json +++ b/src/locales/pt-BR.json @@ -40,14 +40,15 @@ "cmd.help.title": "O que posso digitar?", "cmd.help.sub": "Exemplos para experimentar", "cmd.help.keys": "ajuda exemplos comandos como dicas", - "cmd.help.calc.sub": "Faça uma conta. Enter copia o resultado", - "cmd.help.convert.sub": "Converte comprimento, peso, temperatura e tamanho de arquivo", + "cmd.help.calc.sub": "Contas e conversões, como 5 km in mi. Enter copia o resultado", "cmd.help.timer.ex": "10m chá", "cmd.help.timer.sub": "Inicia um timer. Também 1h30m ou timer 90s", "cmd.help.todo.ex": "todo ligar para o dentista", "cmd.help.todo.sub": "Adiciona à lista de hoje. \"done 1\" marca a primeira", "cmd.help.note.ex": "note a senha do wifi está na geladeira", "cmd.help.note.sub": "Salva uma linha. \"open notes\" abre as notas", + "cmd.help.snippet.ex": "save assinatura", + "cmd.help.snippet.sub": "Guarda o texto que você copiou. Digite ;assinatura para pegar de volta", "cmd.help.search.ex": "g melhor pizza perto de mim", "cmd.help.search.sub": "Pesquisa na web. Também ddg ou b", "cmd.help.text.sub": "Arruma o texto copiado: upper, lower, title, plain, one line, count", @@ -85,6 +86,7 @@ "hint.done": "Concluir", "hint.undo": "Desfazer", "hint.try": "Testar", + "hint.forget": "Esquecer", "calc.sub": "Enter copia o resultado", "calc.bad.title": "Essa conta eu não sei fazer", @@ -96,6 +98,17 @@ "note.saveTitle": "Salvar como nota: {text}", "note.sub": "Adiciona uma linha ao seu arquivo de notas", "clip.sub": "Copiar de novo", + "snippet.none.title": "Nenhum trecho salvo ainda", + "snippet.none.sub": "Copie um texto e digite save e um nome, como save assinatura", + "snippet.save.title": "Salvar o texto copiado como ;{name}", + "snippet.save.replace": "Trocar ;{name} pelo texto copiado", + "snippet.save.sub": "Digite ;{name} quando quiser para copiar de novo", + "snippet.badName.title": "O nome de um trecho é uma palavra só", + "snippet.badName.sub": "Letras e números, até 24, como assinatura ou casa", + "snippet.forget.title": "Esquecer ;{name}", + "snippet.forget.sub": "Apaga o texto salvo", + "snippet.missing.title": "Não existe ;{name}", + "snippet.missing.sub": "Digite ; para ver seus trechos", "todo.item.sub": "Tarefa {n}", "todo.item.subDone": "Tarefa {n}, concluída", @@ -132,6 +145,13 @@ "say.text.none": "Copie um texto primeiro e peça de novo.", "say.text.tooBig": "É texto demais para mim.", "say.text.count": "Palavras: {words}. Caracteres: {chars}. Linhas: {lines}.", + "say.snippet.copied": "Copiei ;{name}. Cole onde quiser.", + "say.snippet.saved": "Salvo. Digite ;{name} para pegar de volta.", + "say.snippet.forgot": "Esqueci ;{name}.", + "say.snippet.gone": "Esse trecho não existe mais.", + "say.snippet.secret": "Isso parece uma chave ou um token, então não vou guardar.", + "say.snippet.tooBig": "É longo demais para um trecho.", + "say.snippet.full": "Meu limite são {max} trechos. Esqueça um primeiro.", "say.awake.on": "Mantendo a tela ligada. Desative pela bandeja ou por aqui.", "say.awake.off": "A tela já pode apagar de novo.", "say.clipboard.on": "Histórico da área de transferência ativado. Só na memória, nunca salvo.", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 592edf2..e76fce3 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -40,14 +40,15 @@ "cmd.help.title": "我可以输入什么?", "cmd.help.sub": "可以试试的例子", "cmd.help.keys": "帮助 例子 示例 命令 怎么用", - "cmd.help.calc.sub": "算一算。按 Enter 复制结果", - "cmd.help.convert.sub": "换算长度、重量、温度和文件大小", + "cmd.help.calc.sub": "计算和换算,例如 5 km in mi。按 Enter 复制结果", "cmd.help.timer.ex": "10m 泡茶", "cmd.help.timer.sub": "开始计时。也可以用 1h30m 或 timer 90s", "cmd.help.todo.ex": "todo 给牙医打电话", "cmd.help.todo.sub": "加到今天的清单。\"done 1\" 勾掉第一项", "cmd.help.note.ex": "note 无线密码贴在冰箱上", "cmd.help.note.sub": "保存一行。\"open notes\" 打开笔记", + "cmd.help.snippet.ex": "save sig", + "cmd.help.snippet.sub": "记住你复制的文字。输入 ;sig 就能取回", "cmd.help.search.ex": "g 附近最好吃的披萨", "cmd.help.search.sub": "搜索网页。也可以用 ddg 或 b", "cmd.help.text.sub": "整理复制的文字:upper、lower、title、plain、one line、count", @@ -85,6 +86,7 @@ "hint.done": "完成", "hint.undo": "撤销", "hint.try": "试试", + "hint.forget": "删除", "calc.sub": "按 Enter 复制结果", "calc.bad.title": "这道题我算不了", @@ -96,6 +98,17 @@ "note.saveTitle": "保存为笔记:{text}", "note.sub": "在笔记文件里添加一行", "clip.sub": "再次复制", + "snippet.none.title": "还没有常用语", + "snippet.none.sub": "先复制一段文字,再输入 save 和一个名字,例如 save sig", + "snippet.save.title": "把复制的文字保存为 ;{name}", + "snippet.save.replace": "用复制的文字替换 ;{name}", + "snippet.save.sub": "随时输入 ;{name} 就能再次复制", + "snippet.badName.title": "常用语的名字只能是一个词", + "snippet.badName.sub": "字母和数字,最多 24 个,例如 sig 或 addr", + "snippet.forget.title": "删除 ;{name}", + "snippet.forget.sub": "删掉保存的文字", + "snippet.missing.title": "没有 ;{name}", + "snippet.missing.sub": "输入 ; 查看你的常用语", "todo.item.sub": "待办 {n}", "todo.item.subDone": "待办 {n},已完成", @@ -132,6 +145,13 @@ "say.text.none": "先复制一些文字,再来找我。", "say.text.tooBig": "文字太多了,我处理不了。", "say.text.count": "单词:{words}。字符:{chars}。行:{lines}。", + "say.snippet.copied": "已复制 ;{name},粘贴到你想要的地方吧。", + "say.snippet.saved": "已保存。输入 ;{name} 就能取回。", + "say.snippet.forgot": "已删除 ;{name}。", + "say.snippet.gone": "那条常用语已经不在了。", + "say.snippet.secret": "这看起来像密钥或令牌,我不会保存。", + "say.snippet.tooBig": "太长了,不能存为常用语。", + "say.snippet.full": "最多保存 {max} 条常用语,请先删除一条。", "say.awake.on": "正在保持屏幕常亮。可以在托盘或这里关闭。", "say.awake.off": "屏幕又可以睡眠了。", "say.clipboard.on": "剪贴板历史已开启。只存在内存中,从不保存。", diff --git a/src/tools/actions.js b/src/tools/actions.js index 88ddf21..3a38377 100644 --- a/src/tools/actions.js +++ b/src/tools/actions.js @@ -12,6 +12,7 @@ const system = require('./system'); const timersLib = require('./timers'); const todosLib = require('./todos'); const textfix = require('./textfix'); +const snippetsLib = require('./snippets'); const { translator, FALLBACK } = require('../i18n'); const EN = translator(FALLBACK); @@ -20,6 +21,8 @@ const tr = (api) => (typeof api.t === 'function' ? api.t : EN); const told = (api, r) => (r && r.key ? tr(api)(r.key, r.vars) : r && r.message); const fail = (api, r) => { if (r && !r.ok && told(api, r)) api.say(told(api, r), { level: 'warn' }); }; const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +// Why a snippet was not saved, as the sentence the pet says. +const SNIPPET_REFUSED = { none: 'say.text.none', big: 'say.snippet.tooBig', secret: 'say.snippet.secret', full: 'say.snippet.full' }; const RUNNERS = { copy(api, a) { @@ -91,6 +94,26 @@ const RUNNERS = { system.copyText(r.text); api.say(tr(api)('say.text.done')); }, + snippet(api, a) { + const hit = snippetsLib.find(api.cfg().snippets, a.name); + if (!hit) { api.say(tr(api)('say.snippet.gone')); return; } + api.rememberClip(hit.text); // our own write is not a new copy for clipboard history + system.copyText(hit.text); + api.say(tr(api)('say.snippet.copied', { name: hit.name })); + }, + // The text comes from the clipboard, read here, and is never said back. + snippetSave(api, a) { + const r = snippetsLib.add(api.cfg().snippets, a.name, system.readText()); + if (!r.ok) { api.say(tr(api)(SNIPPET_REFUSED[r.reason] || 'say.error', { max: snippetsLib.MAX_SNIPPETS })); return; } + api.persistSnippets(r.list); + api.say(tr(api)('say.snippet.saved', { name: snippetsLib.cleanName(a.name) })); + }, + snippetForget(api, a) { + const hit = snippetsLib.find(api.cfg().snippets, a.name); + if (!hit) { api.say(tr(api)('say.snippet.gone')); return; } + api.persistSnippets(snippetsLib.remove(api.cfg().snippets, hit.name)); + api.say(tr(api)('say.snippet.forgot', { name: hit.name })); + }, async system(api, a) { switch (a.what) { case 'snip': { diff --git a/src/tools/commands.js b/src/tools/commands.js index 66b3b62..a134dc4 100644 --- a/src/tools/commands.js +++ b/src/tools/commands.js @@ -16,6 +16,7 @@ const units = require('./units'); const timers = require('./timers'); const todosLib = require('./todos'); const textfix = require('./textfix'); +const snippetsLib = require('./snippets'); const { preview } = require('./clipboard'); const { rank } = require('./fuzzy'); const { translator, FALLBACK } = require('../i18n'); @@ -71,10 +72,10 @@ const textCommands = (t) => textfix.OPS.map((op) => command(t, 'text')( const HELP_QUERY = '?'; const HELP_ROWS = Object.freeze([ { id: 'calc', icon: 'calc', ex: '=12*7.5', fill: '=12*7.5', lead: '=' }, - { id: 'convert', icon: 'convert', ex: '5 km in mi', fill: '5 km in mi' }, { id: 'timer', icon: 'timer', fill: '10m ', lead: '10m' }, { id: 'todo', icon: 'todo', fill: 'todo ', lead: 'todo' }, { id: 'note', icon: 'note', fill: 'note ', lead: 'note' }, + { id: 'snippet', icon: 'snippet', fill: 'save ', lead: 'save' }, { id: 'search', icon: 'search', fill: 'g ', lead: 'g' }, { id: 'text', icon: 'text', ex: 'upper', fill: 'text', lead: 'upper' }, { id: 'pin', icon: 'settings', action: { type: 'system', what: 'settings' } }, @@ -106,9 +107,9 @@ const namedCommands = (q, t) => [helpCommand(t), ...textCommands(t)].filter((r) // A command word and a space with nothing after it yet ("todo "): show that // command's example while you type the rest, not a fuzzy guess at the bare word. // The space is what tells "g " (a search) from the first letter of "gmail". -const WAITING = Object.freeze({ todo: 'todo', note: 'note', g: 'search', ddg: 'search', b: 'search' }); +const WAITING = Object.freeze({ todo: 'todo', note: 'note', g: 'search', ddg: 'search', b: 'search', save: 'snippet' }); function waitingFor(raw, t) { - const m = /^\s*(todo|note|g|ddg|b)\s+$/i.exec(raw); + const m = /^\s*(todo|note|g|ddg|b|save)\s+$/i.exec(raw); const row = m && HELP_ROWS.find((r) => r.id === WAITING[m[1].toLowerCase()]); return row ? [helpRow(t, row, null)] : null; } @@ -162,9 +163,10 @@ function emptyQuery(ctx, t) { // Every result gets an icon id (drawn by launcher-icons.js) and a short verb for // the selected row ("Open", "Copy", "Done"). Filled in one place so a new route // only has to say what it does, not how it looks. -const KIND_ICON = { calc: 'calc', convert: 'convert', search: 'search', note: 'note', todo: 'todo', timer: 'timer', clip: 'clipboard', info: 'info' }; +const KIND_ICON = { calc: 'calc', convert: 'convert', search: 'search', note: 'note', todo: 'todo', timer: 'timer', clip: 'clipboard', snippet: 'snippet', info: 'info' }; const HINTS = { copy: 'hint.copy', search: 'hint.search', note: 'hint.save', openNotes: 'hint.open', todoAdd: 'hint.add', timerStart: 'hint.start', - timerCancel: 'hint.cancel', openShortcut: 'hint.open', clip: 'hint.copy', system: 'hint.run', fill: 'hint.try' }; + timerCancel: 'hint.cancel', openShortcut: 'hint.open', clip: 'hint.copy', system: 'hint.run', fill: 'hint.try', + snippet: 'hint.copy', snippetSave: 'hint.save', snippetForget: 'hint.forget' }; function shortcutIcon(target) { if (/^mailto:/i.test(target)) return 'mail'; @@ -189,11 +191,45 @@ const titled = (text) => text.slice(0, MAX_TITLE); const searchItem = (t, engine, query) => item('search', titled(t('search.title', { engine: ENGINE_NAME[engine] || ENGINE_NAME.google, query })), t('search.sub'), { type: 'search', engine, query }); +// A snippet row with nothing to run: an explanation in the snippet's own colours. +const snippetNote = (t, titleKey, subKey, vars) => item('info', t(titleKey, vars), t(subKey), null, { icon: 'snippet' }); + // Routes that recognise a specific shape. Each returns a result list or null. const ROUTES = [ function help(q, ctx, t) { return /^(\?|help)$/i.test(q) ? helpItems(t) : null; }, + // Saved text. ";" lists it, ";name" finds one. The preview is the user's own + // saved text, cut to one short line like a clip. + function snippetList(q, ctx, t) { + if (!q.startsWith(';')) return null; + const list = ctx.snippets || []; + if (!list.length) return [snippetNote(t, 'snippet.none.title', 'snippet.none.sub')]; + const want = q.slice(1).trim(); + const hits = want ? rank(list, want, (s) => s.name) : list; + return hits.length + ? hits.map((s) => item('snippet', `;${s.name}`, preview(s.text), { type: 'snippet', name: s.name })) + : [snippetNote(t, 'snippet.missing.title', 'snippet.missing.sub', { name: want.slice(0, snippetsLib.MAX_NAME) })]; + }, + // "save sig" keeps whatever is on the clipboard; main reads it when this runs. + // More than one word after it is somebody's sentence, not a name, so it falls + // through to the list and the web search. + function snippetSave(q, ctx, t) { + const m = /^save\s+(\S+)$/i.exec(q); + if (!m) return null; + const name = snippetsLib.cleanName(m[1]); + if (!name) return [snippetNote(t, 'snippet.badName.title', 'snippet.badName.sub')]; + const title = snippetsLib.find(ctx.snippets, name) ? 'snippet.save.replace' : 'snippet.save.title'; + return [item('snippet', t(title, { name }), t('snippet.save.sub', { name }), { type: 'snippetSave', name })]; + }, + function snippetForget(q, ctx, t) { + const m = /^forget\s+;?(\S+)$/i.exec(q); + if (!m) return null; + const hit = snippetsLib.find(ctx.snippets, m[1]); + return hit + ? [item('snippet', t('snippet.forget.title', { name: hit.name }), t('snippet.forget.sub'), { type: 'snippetForget', name: hit.name })] + : [snippetNote(t, 'snippet.missing.title', 'snippet.missing.sub', { name: m[1].slice(0, snippetsLib.MAX_NAME) })]; + }, function math(q, ctx, t) { if (!q.startsWith('=') && !calc.looksLikeMath(q)) return null; const r = calc.evaluate(q); diff --git a/src/tools/index.js b/src/tools/index.js index d8c0314..43654ca 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -57,6 +57,7 @@ function say(text, opts = {}) { function persistTools(patch) { const c = cfg(); d.persist({ ...c, tools: { ...c.tools, ...patch } }); } function persistTodos(todos) { d.persist({ ...cfg(), todos }); } +function persistSnippets(snippets) { d.persist({ ...cfg(), snippets }); } // Today's list, rolled over to today first. A new day clears finished items. function today() { @@ -70,7 +71,7 @@ function ctx() { const c = cfg(); return { platform: process.platform, search: c.tools.search, shortcuts: c.tools.shortcuts, - todos: today(), timers, clips, clipboardOn: c.tools.clipboard, + todos: today(), timers, clips, clipboardOn: c.tools.clipboard, snippets: c.snippets, keepAwake: system.isKeepAwake(), now: Date.now(), t, }; } @@ -269,7 +270,7 @@ function registerIpc() { } const api = { - cfg, say, t, notify: (...a) => d.notify(...a), persistTools, persistTodos, today, + cfg, say, t, notify: (...a) => d.notify(...a), persistTools, persistTodos, persistSnippets, today, timers: () => timers, setTimers, clips: () => clips, rememberClip: (t) => { lastClip = t; }, notesFile, sendAction: (id) => d.sendAction(id), triggerBreak: () => d.triggerBreak(), openSettings: () => d.openSettings(), rebuildTray: () => d.rebuildTray(), diff --git a/src/tools/snippets.js b/src/tools/snippets.js new file mode 100644 index 0000000..be1459b --- /dev/null +++ b/src/tools/snippets.js @@ -0,0 +1,61 @@ +// Text snippets: a short name and a piece of text you saved ("sig", "addr"), +// copied back by typing ";name" in the launcher. Pure list rules, no Electron. +// +// Unlike clipboard history, snippets are written to settings.json as plain text, +// so a key or token is refused at the door. The check is the list of known +// formats only: the stricter clipboard-history guess ("looks like a password") +// would turn away a mixed-case email address or a meeting link, which are +// exactly what people save. + +const { SECRET_PATTERNS } = require('./clipboard'); + +const MAX_SNIPPETS = 30; +const MAX_NAME = 24; +const MAX_TEXT = 2000; +const NAME = /^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u; + +// One word, lower-cased, or null. +function cleanName(name) { + const n = String(name == null ? '' : name).trim().toLowerCase(); + return n.length >= 1 && n.length <= MAX_NAME && NAME.test(n) ? n : null; +} + +const looksSecret = (text) => SECRET_PATTERNS.some((re) => re.test(text.trim())); + +function find(list, name) { + const n = cleanName(name); + return (n && (list || []).find((s) => s.name === n)) || null; +} + +// { ok: true, list, replaced } with a new list, or +// { ok: false, reason: 'name' | 'none' | 'big' | 'secret' | 'full' }. +function add(list, name, text) { + const n = cleanName(name); + const t = String(text == null ? '' : text); + if (!n) return { ok: false, reason: 'name' }; + if (!t.trim()) return { ok: false, reason: 'none' }; + if (t.length > MAX_TEXT) return { ok: false, reason: 'big' }; + if (looksSecret(t)) return { ok: false, reason: 'secret' }; + const current = list || []; + const replaced = current.some((s) => s.name === n); + if (!replaced && current.length >= MAX_SNIPPETS) return { ok: false, reason: 'full' }; + const entry = { name: n, text: t }; + return { ok: true, replaced, list: replaced ? current.map((s) => (s.name === n ? entry : s)) : [...current, entry] }; +} + +function remove(list, name) { + const n = cleanName(name); + return (list || []).filter((s) => s.name !== n); +} + +// What config.normalize keeps from settings.json: the first of each name, and +// nothing that add() would have refused. +function normalizeSnippets(list) { + return (Array.isArray(list) ? list : []).reduce((out, row) => { + if (!row || typeof row !== 'object' || typeof row.text !== 'string') return out; + const r = add(out, row.name, row.text); + return r.ok && !r.replaced ? r.list : out; + }, []); +} + +module.exports = { cleanName, find, add, remove, normalizeSnippets, MAX_SNIPPETS, MAX_NAME, MAX_TEXT }; diff --git a/tests/commands.test.js b/tests/commands.test.js index c7a704d..30ad011 100644 --- a/tests/commands.test.js +++ b/tests/commands.test.js @@ -125,7 +125,7 @@ test('"?" and "help" list things to type, and a row fills the box instead of run const list = suggest('?', ctx()); assert.deepStrictEqual(suggest('help', ctx()), list); assert.ok(list.length <= MAX_RESULTS); - assert.deepStrictEqual(list.map((r) => r.icon), ['calc', 'convert', 'timer', 'todo', 'note', 'search', 'text', 'settings']); + assert.deepStrictEqual(list.map((r) => r.icon), ['calc', 'timer', 'todo', 'note', 'snippet', 'search', 'text', 'settings']); const fills = list.filter((r) => r.action.type === 'fill'); assert.strictEqual(fills.length, 7); for (const r of fills) { @@ -177,7 +177,7 @@ test('a help example says which part of it is the command, in every language', ( for (const code of i18n.CODES) { const t = i18n.translator(code); const leads = suggest('?', ctx({ t })).map((r) => (r.lead ? r.title.slice(0, r.lead) : '')); - assert.deepStrictEqual(leads, ['=', '', '10m', 'todo', 'note', 'g', 'upper', ''], code); + assert.deepStrictEqual(leads, ['=', '10m', 'todo', 'note', 'save', 'g', 'upper', ''], code); assert.strictEqual(top('todo ', ctx({ t })).lead, 4, `${code}: the waiting row marks it too`); } assert.ok(!('lead' in top('gmail')), 'ordinary rows carry no lead'); diff --git a/tests/i18n.test.js b/tests/i18n.test.js index ab1a4ca..dbb82d3 100644 --- a/tests/i18n.test.js +++ b/tests/i18n.test.js @@ -30,10 +30,11 @@ const ctx = (over = {}) => ({ todos: { day: '', items: [{ id: 't1', text: 'write', done: false }, { id: 't2', text: 'gym', done: true }], nudged: '' }, timers: [{ id: 'tm1', endsAt: 5 * 60000, label: 'tea' }, { id: 'tm2', endsAt: 60000, label: '' }], clips: ['a clip'], + snippets: [{ name: 'tea', text: 'two sugars' }], ...over, }); // Queries a route recognises by shape. What they return cannot depend on wording. -const ROUTED = ['', '?', 'todo ', 'g ', '=1+1', '=bad', '5 km in mi', 'g cats', 'note milk', 'todo call', 'done 1', 'done 2', 'done 9', '10m', '10m tea', 'clip']; +const ROUTED = ['', '?', 'todo ', 'g ', 'save ', ';', ';tea', 'save tea', 'save a;b', 'forget tea', 'forget nope', '=1+1', '=bad', '5 km in mi', 'g cats', 'note milk', 'todo call', 'done 1', 'done 2', 'done 9', '10m', '10m tea', 'clip']; // Plus the fuzzy list and the search-or-note fallback, which match on titles. const QUERIES = [...ROUTED, 'gmail', 'text', 'zzqqxx']; @@ -87,7 +88,7 @@ test('every key the code asks for exists, and no key is left unused', () => { const files = ['tools/commands.js', 'tools/actions.js', 'tools/system.js', 'tools/index.js', 'main/tray-menu.js', 'main/updater.js', 'main/notify-history.js', 'main/lang.js']; const code = files.map((f) => codeOnly(read(f))).join('\n'); - const literal = new Set([...code.matchAll(/'((?:launcher|section|cmd|hint|calc|convert|search|note|clip|todo|timer|say|sys|nudge|hotkey|tray|ago)\.[\w.]+)'/g)].map((m) => m[1])); + const literal = new Set([...code.matchAll(/'((?:launcher|section|cmd|hint|calc|convert|search|note|clip|snippet|todo|timer|say|sys|nudge|hotkey|tray|ago)\.[\w.]+)'/g)].map((m) => m[1])); for (const key of literal) assert.ok(key in EN, `the code asks for "${key}", which en.json does not have`); // Keys built at run time: `cmd.snip.sub.${os}`, `section.${section}`, `pet.${id}.label`, `coat.${slug}`. const stems = [...code.matchAll(/`(?:[^`]*\$\{t\(`)?((?:launcher|section|cmd|pet|coat|jam)\.[\w.]*)\$\{/g)].map((m) => m[1]); diff --git a/tests/launcher-wiring.test.js b/tests/launcher-wiring.test.js index b4d7263..972a42b 100644 --- a/tests/launcher-wiring.test.js +++ b/tests/launcher-wiring.test.js @@ -78,10 +78,10 @@ test('every icon the router asks for is drawn, and icons are built as DOM, not m shortcuts: [{ id: 's1', label: 'a', target: 'https://a.com/' }, { id: 's2', label: 'b', target: 'C:\\b' }, { id: 's3', label: 'c', target: 'C:\\c.exe' }, { id: 's4', label: 'd', target: 'C:\\d.pdf' }, { id: 's5', label: 'e', target: 'mailto:e@x.y' }], todos: { day: '', items: [{ id: 't1', text: 'x', done: false }], nudged: '' }, - timers: [{ id: 'tm1', endsAt: 1000, label: 't' }], clips: ['clip'], + timers: [{ id: 'tm1', endsAt: 1000, label: 't' }], clips: ['clip'], snippets: [{ name: 'sig', text: 'x' }], }; const used = new Set(); - for (const q of ['', '?', 'text', '=1+1', '5 km in mi', 'g x', 'note x', 'todo x', 'done 1', '10m', 'clip', '=bad', 'zzqq']) { + for (const q of ['', '?', 'text', ';', ';zz', 'save sig', 'save a;b', 'forget sig', '=1+1', '5 km in mi', 'g x', 'note x', 'todo x', 'done 1', '10m', 'clip', '=bad', 'zzqq']) { for (const r of suggest(q, ctx)) used.add(r.icon); } for (const id of used) assert.ok(have.has(id), `icon "${id}" is missing from launcher-icons.js`); diff --git a/tests/snippets.test.js b/tests/snippets.test.js new file mode 100644 index 0000000..068af6f --- /dev/null +++ b/tests/snippets.test.js @@ -0,0 +1,133 @@ +// Text snippets: a name and a piece of text you saved, copied back with ";name". +// The list rules are pure; the runner is tested with the clipboard stubbed. +const test = require('node:test'); +const assert = require('node:assert'); + +const snippets = require('../src/tools/snippets'); +const { suggest } = require('../src/tools/commands'); +const config = require('../src/config'); + +const LIST = [{ name: 'sig', text: 'Best,\nJohn' }, { name: 'addr', text: '12 Baker Street, London' }]; +const ctx = (over = {}) => ({ + platform: 'win32', search: 'google', shortcuts: [], timers: [], clips: [], clipboardOn: false, keepAwake: false, now: 0, + todos: { day: '', items: [], nudged: '' }, snippets: LIST, ...over, +}); +const top = (q, c = ctx()) => suggest(q, c)[0]; + +test('a name is one word of letters and digits, lower-cased', () => { + assert.strictEqual(snippets.cleanName(' Sig '), 'sig'); + assert.strictEqual(snippets.cleanName('home-addr_2'), 'home-addr_2'); + assert.strictEqual(snippets.cleanName('dirección'), 'dirección'); + for (const bad of ['', 'two words', 'a;b', ';sig', 'x'.repeat(25), null, '../etc']) assert.strictEqual(snippets.cleanName(bad), null, String(bad)); +}); + +test('add saves a new snippet, replaces one with the same name, and refuses what it should', () => { + const one = snippets.add([], 'Sig', 'Best,\nJohn'); + assert.deepStrictEqual(one, { ok: true, list: [{ name: 'sig', text: 'Best,\nJohn' }], replaced: false }); + const two = snippets.add(one.list, 'sig', 'Cheers'); + assert.deepStrictEqual(two.list, [{ name: 'sig', text: 'Cheers' }]); + assert.strictEqual(two.replaced, true); + assert.notStrictEqual(two.list, one.list, 'the old list is left alone'); + + assert.deepStrictEqual(snippets.add([], 'two words', 'x'), { ok: false, reason: 'name' }); + assert.deepStrictEqual(snippets.add([], 'sig', ' \n'), { ok: false, reason: 'none' }); + assert.deepStrictEqual(snippets.add([], 'sig', 'x'.repeat(snippets.MAX_TEXT + 1)), { ok: false, reason: 'big' }); + const full = Array.from({ length: snippets.MAX_SNIPPETS }, (_, i) => ({ name: `n${i}`, text: 't' })); + assert.deepStrictEqual(snippets.add(full, 'extra', 'x'), { ok: false, reason: 'full' }); + assert.strictEqual(snippets.add(full, 'n3', 'new').ok, true, 'replacing one is fine when full'); +}); + +test('a key or token is never written to disk, and ordinary text is', () => { + // Built in pieces so no scanner mistakes this file for a leak. + const secrets = [`gh${'p'}_${'a1B2'.repeat(6)}`, `-----BEGIN RSA ${'PRIVATE'} KEY-----\nabc`, `s${'k'}-${'x'.repeat(24)}`]; + for (const s of secrets) assert.deepStrictEqual(snippets.add([], 'k', s), { ok: false, reason: 'secret' }, s.slice(0, 6)); + // Things the stricter clipboard-history filter would refuse, and people do save. + for (const fine of ['John.Doe99@Example.com', 'https://zoom.us/j/1234567890?pwd=abcDEF123456789012345678', '+1 (555) 010-2030']) { + assert.strictEqual(snippets.add([], 'k', fine).ok, true, fine); + } +}); + +test('remove and find go by cleaned name', () => { + assert.deepStrictEqual(snippets.find(LIST, ' SIG '), LIST[0]); + assert.strictEqual(snippets.find(LIST, 'nope'), null); + assert.deepStrictEqual(snippets.remove(LIST, 'sig'), [LIST[1]]); + assert.deepStrictEqual(snippets.remove(LIST, 'nope'), LIST); +}); + +test('the config keeps only well-formed snippets', () => { + const dirty = [{ name: 'Sig', text: 'a' }, { name: 'sig', text: 'dup' }, { name: 'bad name', text: 'x' }, { name: 'ok', text: '' }, null, 'str', { name: 'n', text: 5 }]; + assert.deepStrictEqual(config.normalize({ snippets: dirty }).snippets, [{ name: 'sig', text: 'a' }]); + assert.deepStrictEqual(config.normalize({}).snippets, []); + assert.deepStrictEqual(config.normalize({ snippets: 'junk' }).snippets, []); + // Saving another setting from the Settings window must not drop them. + const kept = config.normalize({ ...config.normalize({ snippets: LIST }), tools: { search: 'bing' } }); + assert.deepStrictEqual(kept.snippets, LIST); +}); + +test('";" lists snippets and ";name" finds one, showing a one-line preview', () => { + const all = suggest(';', ctx()); + assert.deepStrictEqual(all.map((r) => r.title), [';sig', ';addr']); + assert.strictEqual(all[0].subtitle, 'Best, John', 'line breaks collapse in the preview'); + assert.deepStrictEqual(all[0].action, { type: 'snippet', name: 'sig' }); + assert.strictEqual(all[0].icon, 'snippet'); + assert.strictEqual(all[0].hint, 'Copy'); + assert.deepStrictEqual(top(';ad').action, { type: 'snippet', name: 'addr' }); + assert.strictEqual(top(';zzz').action, null); + assert.strictEqual(top(';', ctx({ snippets: [] })).action, null, 'an empty list explains how to make one'); + const long = suggest(';', ctx({ snippets: [{ name: 'l', text: 'z'.repeat(500) }] }))[0]; + assert.ok(long.subtitle.length <= 80); +}); + +test('"save name" and "forget name" build actions that carry a name and never the text', () => { + assert.deepStrictEqual(top('save phone').action, { type: 'snippetSave', name: 'phone' }); + assert.match(top('save phone').title, /;phone/); + assert.match(top('save sig').title, /^Replace/); + assert.strictEqual(top('save a;b').action, null, 'a bad name is explained, not saved'); + // Several words after "save" is somebody's sentence, so it is left to search. + assert.deepStrictEqual(suggest('save the date ideas', ctx()).map((r) => r.action.type), ['search', 'note']); + assert.deepStrictEqual(suggest('forget about it', ctx()).map((r) => r.action.type), ['search', 'note']); + assert.deepStrictEqual(top('forget sig').action, { type: 'snippetForget', name: 'sig' }); + assert.strictEqual(top('forget nope').action, null); + assert.strictEqual(top('save ').action, null, 'waiting for a name shows the example'); + assert.strictEqual(top('save ').icon, 'snippet'); + for (const q of ['save phone', 'forget sig']) assert.ok(!JSON.stringify(top(q)).includes('Baker'), q); +}); + +test('running them reads the clipboard in main and says what happened without quoting it', async () => { + const system = require('../src/tools/system'); + const { runAction } = require('../src/tools/actions'); + const real = { readText: system.readText, copyText: system.copyText }; + let board = 'my office door code'; + let saved = [...LIST]; + const said = []; + const remembered = []; + system.readText = () => board; + system.copyText = (s) => { board = s; }; + const api = { + say: (t) => said.push(t), rememberClip: (t) => remembered.push(t), + cfg: () => ({ snippets: saved }), persistSnippets: (list) => { saved = list; }, + }; + try { + await runAction(api, { type: 'snippetSave', name: 'door' }); + assert.deepStrictEqual(saved.at(-1), { name: 'door', text: 'my office door code' }); + await runAction(api, { type: 'snippet', name: 'sig' }); + assert.strictEqual(board, 'Best,\nJohn'); + assert.deepStrictEqual(remembered, ['Best,\nJohn'], 'clipboard history is told this copy is ours'); + await runAction(api, { type: 'snippetForget', name: 'door' }); + assert.strictEqual(snippets.find(saved, 'door'), null); + await runAction(api, { type: 'snippet', name: 'door' }); + board = ''; + await runAction(api, { type: 'snippetSave', name: 'empty' }); + assert.strictEqual(snippets.find(saved, 'empty'), null); + } finally { + Object.assign(system, real); + } + assert.deepStrictEqual(said, [ + 'Saved. Type ;door to get it back.', + 'Copied ;sig. Paste it where you want.', + 'Forgot ;door.', + 'That snippet is gone.', + 'Copy some text first, then ask me again.', + ]); + assert.ok(!said.join(' ').includes('office')); +}); From 1d4eca6ddf00b4662a997fd87aa5fac481d1226c Mon Sep 17 00:00:00 2001 From: JohnsonKC201 Date: Fri, 9 Oct 2026 00:47:57 -0400 Subject: [PATCH 2/3] docs: snippets in the README, the feature guide and the changelog --- CHANGELOG.md | 1 + README.md | 3 +++ docs/features.md | 9 +++++++++ 3 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5019cee..8511e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Notable changes to **pixelpets**. All art and sound are original/procedural (no - **Report a problem.** From the tray or Settings > Tools. It shows the exact text first (versions, OS, which features are switched on by name only, and the recent redacted log), then opens GitHub's bug form in your browser, prefilled, when you click. The app itself sends nothing. ### Quick Tools +- **Snippets: save text once, get it back by name.** Copy your address, your email sign-off or a meeting link, type `save sig`, and from then on `;sig` copies it again. `;` lists what you have saved and `forget sig` deletes one. Saving reads the clipboard at that moment, so the launcher never handles the text, and the pet never says it back. Snippets are kept in `settings.json` as plain text; a private key or a token in a known format is refused. In all eight languages, and in the `?` list. - **Type `?` to see what you can type.** Every tool in the launcher was a typed command you had to read the docs to learn. `?` (or `help`, or "What can I type?" at the bottom of the list) now shows an example of each one, and pressing Enter on an example puts its start in the box for you to finish, so `todo ` is waiting for the rest of the sentence. In all eight languages. - **Fix the text you just copied.** `upper`, `lower`, `title`, `plain` (drops fonts, colours and links before you paste), `one line` (mends the broken lines of a paragraph copied from a PDF or an email) and `count` (words, characters, lines). Typing `text` lists all six. They work whether or not clipboard history is on, because nothing is kept: the clipboard is read only at the moment you run one, the launcher never shows what is on it, and the pet does not repeat it. - **One box for everyday things.** Ctrl+Shift+Space (Cmd+Shift+Space on a Mac) or right-clicking the pet opens a keyboard-first launcher: pinned sites, folders and apps, a calculator and unit converter, web search, quick notes, today's to-dos (five at most, with a cheer when you finish one and one afternoon nudge), timers the pet announces, screen snip, lock screen and keep awake. diff --git a/README.md b/README.md index ce9641f..2829a2d 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ Right-click the pet, or press Ctrl+Shift+Space (Cmd+Shift+Space on a Mac) from a | `10m tea`, `1h30m` | A timer the pet announces when it's up | | `todo call the dentist`, `done 1` | Today's to-dos, five at most. The pet cheers when you tick one off | | `note the wifi code is on the fridge` | Adds a line to your notes file | +| `save sig`, then `;sig` | Keeps the text you copied under a name, and copies it back when you type the name | | `=12*7.5`, `5 km in mi`, `72 f to c` | Calculates or converts. Enter copies the answer | | `plain`, `upper`, `one line`, `count` | Fixes the text you just copied, ready to paste | | `g best pizza near me` | Searches the web | @@ -140,6 +141,8 @@ pixelpets keeps a small diagnostic log on your machine (`logs/pixelpets.log` in Clipboard history is off by default. When it's on, it keeps the last 20 things you copied in memory only, skips anything that looks like a password or key, and forgets everything when you lock the screen or quit. +Snippets you save with `save` are different from clipboard history: they are kept, as plain text, in `settings.json`. Text in a known key or token format is refused, but nothing can recognise every password, so don't save one. + Settings are saved to `settings.json` in your app-data folder: `%APPDATA%/pixelpets/` on Windows, `~/Library/Application Support/pixelpets/` on macOS. [SECURITY.md](SECURITY.md) explains how to report a vulnerability privately. diff --git a/docs/features.md b/docs/features.md index f3d90fd..f82d0d6 100644 --- a/docs/features.md +++ b/docs/features.md @@ -166,6 +166,15 @@ today's to-dos, running timers and a few one-press actions. from clipboard history and works with it off: the clipboard is read once, when you press Enter, nothing is stored, and neither the launcher nor the pet shows the text. Copied text over 200,000 characters is left alone. +- **Snippets.** Text you type again and again: an address, an email sign-off, + a meeting link. Copy it once, type `save sig`, and from then on `;sig` copies + it back. `;` on its own lists them all, `save sig` again replaces it with + whatever you have copied now, and `forget sig` deletes it. A name is one + word, up to 24 letters and digits. You can keep 30, each up to 2,000 + characters. Unlike clipboard history, snippets are meant to last, so they + are written to `settings.json` as plain text. Text in a known key or token + format (a private key, a GitHub or AWS token) is refused, but no check + recognises every password, so do not save one. - **Low battery and eye rest.** The pet speaks up once when a laptop reaches 20% unplugged (on by default; it re-arms after charging). The optional 20-20-20 nudge reminds you to look away every 20 minutes, and skips meetings, quiet hours and time you are away. From e81642c31516e314c4c9fa147f803b5b3baab09d Mon Sep 17 00:00:00 2001 From: JohnsonKC201 Date: Fri, 9 Oct 2026 00:49:59 -0400 Subject: [PATCH 3/3] fix(tools): snippets survive a Settings save, take Hindi names, and list all thirty Found in review, before any release. - Settings sends back its whole copy of the config on every save. A copy from before a snippet was saved put the old list back, and thirty full snippets (about 74 KB) pushed every save past the 64 KB junk limit, so toggles silently did nothing. settings:save now drops the snippets key, which Settings has no editor for. - A name could not contain a combining mark, which ruled out every Hindi word with a vowel sign. Marks are allowed after the first character, and names are NFKC folded so look-alikes are one name. - forget followed by a word that is no snippet (forget password) is left to the web search again. - ; showed eight snippets at most, and it is the only place to see them all. It lists all thirty; the launcher list scrolls. - save ;sig is the same as save sig. - Refuse a few more credential shapes with no vendor prefix: a connection string with a password in it, a password: or api_key= line, Stripe, GitLab and npm tokens, a Bearer header. --- src/config.js | 5 ++--- src/main/settings-ipc.js | 10 ++++++++-- src/tools/commands.js | 12 +++++++++--- src/tools/snippets.js | 26 ++++++++++++++++++++++---- tests/i18n.test.js | 2 +- tests/settings-ipc.test.js | 14 ++++++++++++++ tests/snippets.test.js | 18 ++++++++++++++++-- 7 files changed, 72 insertions(+), 15 deletions(-) diff --git a/src/config.js b/src/config.js index fe52db3..90a7122 100644 --- a/src/config.js +++ b/src/config.js @@ -84,9 +84,8 @@ const DEFAULTS = { shortcuts: [], // [{ id, label, target }] validated by tools/shortcuts.js }, todos: { day: '', items: [], nudged: '' }, // today's list, see tools/todos.js - // Saved text, copied back with ";name" in the launcher: [{ name, text }]. Top - // level, like todos, because Settings saves the whole `tools` object from its - // own copy and would overwrite a snippet saved while it was open. + // Saved text, copied back with ";name" in the launcher: [{ name, text }]. Only + // the launcher writes it: settings:save drops this key (src/main/settings-ipc.js). snippets: [], }; diff --git a/src/main/settings-ipc.js b/src/main/settings-ipc.js index 45c19bd..2b7cb6e 100644 --- a/src/main/settings-ipc.js +++ b/src/main/settings-ipc.js @@ -15,6 +15,11 @@ const PET_ACTIONS = new Set(['companion', 'give', 'play', 'stretch', 'groom', 'l // A settings payload bigger than this is junk, not a settings change. const MAX_SETTINGS_BYTES = 65536; +// Parts of the config Settings has no editor for. The window sends back its +// whole copy of the config on every save, so without this a copy from before +// you saved a snippet would put the old list back, and a full set of snippets +// would push every save past the size limit above. +const NOT_SETTINGS = new Set(['snippets']); const COATS_FILE_FILTER = [{ name: 'JSON', extensions: ['json'] }]; /** @@ -83,8 +88,9 @@ function registerSettingsIpc(d) { // config.normalize is the real sanitizer; this caps the in-flight // allocation and drops junk payloads. if (!partial || typeof partial !== 'object' || Array.isArray(partial)) return getCfg(); - try { if (JSON.stringify(partial).length > MAX_SETTINGS_BYTES) return getCfg(); } catch (e) { return getCfg(); } - persist({ ...getCfg(), ...partial }); + const mine = Object.fromEntries(Object.entries(partial).filter(([key]) => !NOT_SETTINGS.has(key))); + try { if (JSON.stringify(mine).length > MAX_SETTINGS_BYTES) return getCfg(); } catch (e) { return getCfg(); } + persist({ ...getCfg(), ...mine }); return getCfg(); }); diff --git a/src/tools/commands.js b/src/tools/commands.js index a134dc4..ddf53a3 100644 --- a/src/tools/commands.js +++ b/src/tools/commands.js @@ -194,6 +194,10 @@ const searchItem = (t, engine, query) => // A snippet row with nothing to run: an explanation in the snippet's own colours. const snippetNote = (t, titleKey, subKey, vars) => item('info', t(titleKey, vars), t(subKey), null, { icon: 'snippet' }); +// Routes that may return more than MAX_RESULTS rows. ";" is the only way to see +// every snippet, and the launcher list scrolls. +const ROUTE_LIMIT = Object.freeze({ snippetList: snippetsLib.MAX_SNIPPETS }); + // Routes that recognise a specific shape. Each returns a result list or null. const ROUTES = [ function help(q, ctx, t) { @@ -215,7 +219,7 @@ const ROUTES = [ // More than one word after it is somebody's sentence, not a name, so it falls // through to the list and the web search. function snippetSave(q, ctx, t) { - const m = /^save\s+(\S+)$/i.exec(q); + const m = /^save\s+;?(\S+)$/i.exec(q); if (!m) return null; const name = snippetsLib.cleanName(m[1]); if (!name) return [snippetNote(t, 'snippet.badName.title', 'snippet.badName.sub')]; @@ -226,9 +230,11 @@ const ROUTES = [ const m = /^forget\s+;?(\S+)$/i.exec(q); if (!m) return null; const hit = snippetsLib.find(ctx.snippets, m[1]); + // With nothing by that name this is an ordinary sentence ("forget password"), + // so it is left to the list and the web search. return hit ? [item('snippet', t('snippet.forget.title', { name: hit.name }), t('snippet.forget.sub'), { type: 'snippetForget', name: hit.name })] - : [snippetNote(t, 'snippet.missing.title', 'snippet.missing.sub', { name: m[1].slice(0, snippetsLib.MAX_NAME) })]; + : null; }, function math(q, ctx, t) { if (!q.startsWith('=') && !calc.looksLikeMath(q)) return null; @@ -291,7 +297,7 @@ function route(query, ctx, t) { for (const route of ROUTES) { const hit = route(q, ctx, t); - if (hit) return hit.slice(0, MAX_RESULTS); + if (hit) return hit.slice(0, ROUTE_LIMIT[route.name] || MAX_RESULTS); } const pool = [...shortcutItems(ctx), ...todoItems(ctx, t), ...timerItems(ctx, t), ...systemCommands(ctx, t), ...namedCommands(q, t)]; diff --git a/src/tools/snippets.js b/src/tools/snippets.js index be1459b..d53cdc6 100644 --- a/src/tools/snippets.js +++ b/src/tools/snippets.js @@ -12,15 +12,33 @@ const { SECRET_PATTERNS } = require('./clipboard'); const MAX_SNIPPETS = 30; const MAX_NAME = 24; const MAX_TEXT = 2000; -const NAME = /^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u; +// Marks are allowed after the first character: in Devanagari and other scripts +// the vowel signs are marks, and a name without them is not a word. +const NAME = /^[\p{L}\p{N}][\p{L}\p{M}\p{N}_-]*$/u; -// One word, lower-cased, or null. +// One word, lower-cased, or null. NFKC folds look-alikes (a full-width "a") +// onto the plain letter, so two names that read the same are the same name. function cleanName(name) { - const n = String(name == null ? '' : name).trim().toLowerCase(); + const n = String(name == null ? '' : name).normalize('NFKC').trim().toLowerCase(); return n.length >= 1 && n.length <= MAX_NAME && NAME.test(n) ? n : null; } -const looksSecret = (text) => SECRET_PATTERNS.some((re) => re.test(text.trim())); +// Credentials the token list above does not cover, in shapes specific enough +// that an address or a sign-off never matches: a connection string with a +// password in it, a "password: ..." line, and a few more vendors' key prefixes. +const MORE_SECRETS = [ + /\b(postgres(ql)?|mysql|mongodb(\+srv)?|redis|amqps?):\/\/[^\s:@/]+:[^\s@]+@/i, + /\b(secret|password|passwd|token|api[_-]?key)\s*[:=]\s*\S{6,}/i, + /\b[sprk]k_(live|test)_[A-Za-z0-9]{8,}/, // Stripe + /\bglpat-[\w-]{16,}/, // GitLab + /\bnpm_[A-Za-z0-9]{36}\b/, // npm + /\bBearer\s+[\w.~+/-]{16,}/, +]; + +const looksSecret = (text) => { + const t = text.trim(); + return SECRET_PATTERNS.some((re) => re.test(t)) || MORE_SECRETS.some((re) => re.test(t)); +}; function find(list, name) { const n = cleanName(name); diff --git a/tests/i18n.test.js b/tests/i18n.test.js index dbb82d3..0c52933 100644 --- a/tests/i18n.test.js +++ b/tests/i18n.test.js @@ -34,7 +34,7 @@ const ctx = (over = {}) => ({ ...over, }); // Queries a route recognises by shape. What they return cannot depend on wording. -const ROUTED = ['', '?', 'todo ', 'g ', 'save ', ';', ';tea', 'save tea', 'save a;b', 'forget tea', 'forget nope', '=1+1', '=bad', '5 km in mi', 'g cats', 'note milk', 'todo call', 'done 1', 'done 2', 'done 9', '10m', '10m tea', 'clip']; +const ROUTED = ['', '?', 'todo ', 'g ', 'save ', ';', ';tea', 'save tea', 'save a;b', 'forget tea', '=1+1', '=bad', '5 km in mi', 'g cats', 'note milk', 'todo call', 'done 1', 'done 2', 'done 9', '10m', '10m tea', 'clip']; // Plus the fuzzy list and the search-or-note fallback, which match on titles. const QUERIES = [...ROUTED, 'gmail', 'text', 'zzqqxx']; diff --git a/tests/settings-ipc.test.js b/tests/settings-ipc.test.js index 7b5fe91..1605ab2 100644 --- a/tests/settings-ipc.test.js +++ b/tests/settings-ipc.test.js @@ -97,3 +97,17 @@ test('themes:add stores the coat and tells every window', () => { assert.deepStrictEqual(invoke('themes:add', { name: 'Mint' }), [{ name: 'Mint' }]); assert.strictEqual(state.changed, 1); }); + +test('settings:save never touches snippets, which only the launcher writes', () => { + const mine = [{ name: 'sig', text: 'Best' }]; + const { state, invoke } = setup({ name: 'Mochi', snippets: mine }); + // Settings sends back its whole copy of the config, here one from before a snippet was saved. + invoke('settings:save', { name: 'Tofu', snippets: [] }); + assert.deepStrictEqual(state.saved.at(-1), { name: 'Tofu', snippets: mine }); + // A full set of snippets is about 60 KB. That must not make an ordinary save look like junk. + const full = Array.from({ length: 30 }, (_, i) => ({ name: `n${i}`, text: 'x'.repeat(2000) })); + const big = setup({ name: 'Mochi', snippets: full }); + big.invoke('settings:save', { name: 'Tofu', snippets: full }); + assert.strictEqual(big.state.saved.at(-1).name, 'Tofu'); + assert.strictEqual(big.state.saved.at(-1).snippets, full); +}); diff --git a/tests/snippets.test.js b/tests/snippets.test.js index 068af6f..281e3ae 100644 --- a/tests/snippets.test.js +++ b/tests/snippets.test.js @@ -18,6 +18,11 @@ test('a name is one word of letters and digits, lower-cased', () => { assert.strictEqual(snippets.cleanName(' Sig '), 'sig'); assert.strictEqual(snippets.cleanName('home-addr_2'), 'home-addr_2'); assert.strictEqual(snippets.cleanName('dirección'), 'dirección'); + // Hindi ships as a language, and its vowel signs are combining marks. + const pata = String.fromCodePoint(0x92a, 0x924, 0x93e); + assert.strictEqual(snippets.cleanName(pata), pata); + assert.strictEqual(snippets.cleanName(String.fromCodePoint(0x93e)), null, 'a name cannot start with a bare mark'); + assert.strictEqual(snippets.cleanName(String.fromCodePoint(0xff41, 0xff44, 0xff44, 0xff52)), 'addr', 'full-width letters fold to plain ones'); for (const bad of ['', 'two words', 'a;b', ';sig', 'x'.repeat(25), null, '../etc']) assert.strictEqual(snippets.cleanName(bad), null, String(bad)); }); @@ -41,8 +46,12 @@ test('a key or token is never written to disk, and ordinary text is', () => { // Built in pieces so no scanner mistakes this file for a leak. const secrets = [`gh${'p'}_${'a1B2'.repeat(6)}`, `-----BEGIN RSA ${'PRIVATE'} KEY-----\nabc`, `s${'k'}-${'x'.repeat(24)}`]; for (const s of secrets) assert.deepStrictEqual(snippets.add([], 'k', s), { ok: false, reason: 'secret' }, s.slice(0, 6)); + // Credentials with no vendor prefix, in shapes an address never has. + const more = ['postgres://app:hunter22@db.example.com/prod', 'password: hunter2!', 'API_KEY=abcdef123456', `s${'k'}_live_${'a1b2c3d4e5'}`, + `glpat-${'x'.repeat(20)}`, `Authorization: Bearer ${'abc123'.repeat(4)}`]; + for (const s of more) assert.deepStrictEqual(snippets.add([], 'k', s), { ok: false, reason: 'secret' }, s.slice(0, 12)); // Things the stricter clipboard-history filter would refuse, and people do save. - for (const fine of ['John.Doe99@Example.com', 'https://zoom.us/j/1234567890?pwd=abcDEF123456789012345678', '+1 (555) 010-2030']) { + for (const fine of ['The password hint is my first pet', 'Token of thanks: lunch on me', 'John.Doe99@Example.com', 'https://zoom.us/j/1234567890?pwd=abcDEF123456789012345678', '+1 (555) 010-2030']) { assert.strictEqual(snippets.add([], 'k', fine).ok, true, fine); } }); @@ -74,6 +83,9 @@ test('";" lists snippets and ";name" finds one, showing a one-line preview', () assert.deepStrictEqual(top(';ad').action, { type: 'snippet', name: 'addr' }); assert.strictEqual(top(';zzz').action, null); assert.strictEqual(top(';', ctx({ snippets: [] })).action, null, 'an empty list explains how to make one'); + // ";" is the only place to see them all, so it is not cut at eight rows. + const many = Array.from({ length: snippets.MAX_SNIPPETS }, (_, i) => ({ name: `n${i}`, text: 't' })); + assert.strictEqual(suggest(';', ctx({ snippets: many })).length, snippets.MAX_SNIPPETS); const long = suggest(';', ctx({ snippets: [{ name: 'l', text: 'z'.repeat(500) }] }))[0]; assert.ok(long.subtitle.length <= 80); }); @@ -87,7 +99,9 @@ test('"save name" and "forget name" build actions that carry a name and never th assert.deepStrictEqual(suggest('save the date ideas', ctx()).map((r) => r.action.type), ['search', 'note']); assert.deepStrictEqual(suggest('forget about it', ctx()).map((r) => r.action.type), ['search', 'note']); assert.deepStrictEqual(top('forget sig').action, { type: 'snippetForget', name: 'sig' }); - assert.strictEqual(top('forget nope').action, null); + assert.deepStrictEqual(top('save ;phone').action, { type: 'snippetSave', name: 'phone' }, 'a leading ; is the same name'); + // No snippet by that name: an ordinary sentence, left to search. + assert.deepStrictEqual(suggest('forget password', ctx()).map((r) => r.action.type), ['search', 'note']); assert.strictEqual(top('save ').action, null, 'waiting for a name shows the example'); assert.strictEqual(top('save ').icon, 'snippet'); for (const q of ['save phone', 'forget sig']) assert.ok(!JSON.stringify(top(q)).includes('Baker'), q);