commit 30c21cb8a3f6c8c64e14c327c10dec2ae3f17b24 Author: Daniel Dada Date: Mon Jun 16 16:33:47 2025 +0300 init diff --git a/_locales/en/messages.json b/_locales/en/messages.json new file mode 100644 index 0000000..82fbe69 --- /dev/null +++ b/_locales/en/messages.json @@ -0,0 +1,68 @@ +{ + "extension_name": { + "message": "Goose Highlighter" + }, + "select_list": { + "message": "Select List:" + }, + "new_list": { + "message": "+ New List" + }, + "delete_list": { + "message": "Delete List" + }, + "list_name": { + "message": "List Name:" + }, + "background": { + "message": "Background:" + }, + "foreground": { + "message": "Foreground:" + }, + "enable_highlight": { + "message": "Enable Highlight" + }, + "paste_hint": { + "message": "Paste words here" + }, + "apply_paste": { + "message": "Add Words" + }, + "select_all": { + "message": "Select All" + }, + "delete_selected": { + "message": "Delete Selected" + }, + "disable_selected": { + "message": "Disable Selected" + }, + "enable_selected": { + "message": "Enable Selected" + }, + "import_list": { + "message": "Import JSON" + }, + "export_list": { + "message": "Export JSON" + }, + "default_list_name": { + "message": "Default List" + }, + "new_list_name": { + "message": "New List" + }, + "word_active_label": { + "message": "active" + }, + "invalid_json_error": { + "message": "Invalid JSON file" + }, + "confirm_delete_list": { + "message": "Are you sure you want to delete this list?" + }, + "confirm_delete_words": { + "message": "Are you sure you want to delete the selected words?" + } +} \ No newline at end of file diff --git a/_locales/ru/messages.json b/_locales/ru/messages.json new file mode 100644 index 0000000..1524286 --- /dev/null +++ b/_locales/ru/messages.json @@ -0,0 +1,68 @@ +{ + "extension_name": { + "message": "Goose Highlighter" + }, + "select_list": { + "message": "Выберите список:" + }, + "new_list": { + "message": "+ Новый список" + }, + "delete_list": { + "message": "Удалить список" + }, + "list_name": { + "message": "Имя списка:" + }, + "background": { + "message": "Фон:" + }, + "foreground": { + "message": "Текст:" + }, + "enable_highlight": { + "message": "Включить выделение" + }, + "paste_hint": { + "message": "Вставьте список слов здесь" + }, + "apply_paste": { + "message": "Добавить слова" + }, + "select_all": { + "message": "Выбрать все" + }, + "delete_selected": { + "message": "Удалить выбранное" + }, + "disable_selected": { + "message": "Отключить выбранное" + }, + "enable_selected": { + "message": "Включить выбранное" + }, + "import_list": { + "message": "Импорт JSON" + }, + "export_list": { + "message": "Экспорт JSON" + }, + "default_list_name": { + "message": "Список по умолчанию" + }, + "new_list_name": { + "message": "Новый список" + }, + "word_active_label": { + "message": "активно" + }, + "invalid_json_error": { + "message": "Неверный JSON файл" + }, + "confirm_delete_list": { + "message": "Вы уверены, что хотите удалить этот список?" + }, + "confirm_delete_words": { + "message": "Вы уверены, что хотите удалить выбранные слова?" + } +} \ No newline at end of file diff --git a/background.js b/background.js new file mode 100644 index 0000000..e69de29 diff --git a/content.js b/content.js new file mode 100644 index 0000000..9760e16 --- /dev/null +++ b/content.js @@ -0,0 +1,53 @@ +function escapeRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function highlightWords(lists) { + const textNodes = []; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { + acceptNode: node => { + if (node.parentNode && ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME'].includes(node.parentNode.nodeName)) return NodeFilter.FILTER_REJECT; + if (!node.nodeValue.trim()) return NodeFilter.FILTER_SKIP; + return NodeFilter.FILTER_ACCEPT; + } + }); + + while (walker.nextNode()) textNodes.push(walker.currentNode); + + const activeWords = []; + + for (const list of lists) { + if (!list.active) continue; + for (const word of list.words) { + if (!word.active) continue; + activeWords.push({ + text: word.wordStr, + background: word.background || list.background, + foreground: word.foreground || list.foreground + }); + } + } + + if (activeWords.length === 0) return; + + const wordMap = new Map(); + for (const word of activeWords) wordMap.set(word.text.toLowerCase(), word); + + const pattern = new RegExp(`(${Array.from(wordMap.keys()).map(escapeRegex).join('|')})`, 'gi'); + + for (const node of textNodes) { + if (!pattern.test(node.nodeValue)) continue; + + const span = document.createElement('span'); + span.innerHTML = node.nodeValue.replace(pattern, match => { + const word = wordMap.get(match.toLowerCase()) || { background: '#ffff00', foreground: '#000000' }; + return `${match}`; + }); + + node.parentNode.replaceChild(span, node); + } +} + +chrome.storage.local.get("lists", ({ lists }) => { + if (Array.isArray(lists)) highlightWords(lists); +}); diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000..37a6723 Binary files /dev/null and b/icons/icon128.png differ diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000..c18953b Binary files /dev/null and b/icons/icon16.png differ diff --git a/icons/icon32.png b/icons/icon32.png new file mode 100644 index 0000000..8f210cd Binary files /dev/null and b/icons/icon32.png differ diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000..3e466bf Binary files /dev/null and b/icons/icon48.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..22d4c85 --- /dev/null +++ b/manifest.json @@ -0,0 +1,26 @@ +{ + "manifest_version": 3, + "name": "Goose Highlighter", + "version": "1.0", + "description": "Highlight text on web pages", + "default_locale": "en", + "permissions": ["storage", "scripting", "activeTab"], + "action": { + "default_popup": "popup/popup.html", + "default_icon": "icons/icon128.png" + }, + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_idle" + } + ], + "icons": { + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/popup/popup.css b/popup/popup.css new file mode 100644 index 0000000..6a1d2d7 --- /dev/null +++ b/popup/popup.css @@ -0,0 +1,77 @@ +body { + font-family: Arial, sans-serif; + width: 360px; + margin: 0; + padding: 0; + background: #f9f9f9; + color: #333; +} + +.container { + padding: 10px; +} + +h1 { + font-size: 1.4em; + margin-bottom: 10px; +} + +.section { + margin-bottom: 12px; +} + +input[type="text"], +textarea, +select { + width: 100%; + box-sizing: border-box; + margin-top: 4px; + margin-bottom: 8px; + padding: 6px; + border-radius: 4px; + border: 1px solid #ccc; +} + +textarea { + height: 60px; + resize: vertical; +} + +button { + margin: 4px 2px; + padding: 6px 10px; + font-size: 0.9em; + border: none; + border-radius: 4px; + background-color: #007bff; + color: white; + cursor: pointer; +} + +button:hover { + background-color: #0056b3; +} + +#wordList > div { + display: flex; + align-items: center; + margin-bottom: 6px; + gap: 4px; +} + +#wordList input[type="text"] { + flex: 2; +} + +#wordList input[type="color"] { + width: 30px; + height: 30px; + padding: 0; + border: none; +} + +label { + display: inline-flex; + align-items: center; + gap: 6px; +} \ No newline at end of file diff --git a/popup/popup.html b/popup/popup.html new file mode 100644 index 0000000..377cf75 --- /dev/null +++ b/popup/popup.html @@ -0,0 +1,51 @@ + + + + + + <span data-i18n="extension_name"></span> + + + +
+

Goose Highlighter

+ +
+ + + + +
+ +
+ + + + +
+ +
+ + +
+ +
+ + + + +
+ +
+ +
+ + + +
+
+ + + + + \ No newline at end of file diff --git a/popup/popup.js b/popup/popup.js new file mode 100644 index 0000000..9c520bc --- /dev/null +++ b/popup/popup.js @@ -0,0 +1,197 @@ +const listSelect = document.getElementById("listSelect"); +const listName = document.getElementById("listName"); +const listBg = document.getElementById("listBg"); +const listFg = document.getElementById("listFg"); +const listActive = document.getElementById("listActive"); +const bulkPaste = document.getElementById("bulkPaste"); +const wordList = document.getElementById("wordList"); +const importInput = document.getElementById("importInput"); + +let lists = []; +let currentListIndex = 0; + +async function save() { + await chrome.storage.local.set({ lists }); + renderLists(); + renderWords(); +} + +async function load() { + const res = await chrome.storage.local.get("lists"); + lists = res.lists || []; + if (!lists.length) { + lists.push({ + id: Date.now(), + name: chrome.i18n.getMessage("default_list_name"), + background: "#ffff00", + foreground: "#000000", + active: true, + words: [] + }); + } + renderLists(); + renderWords(); +} + +function renderLists() { + listSelect.innerHTML = lists.map((list, index) => ``).join(""); + listSelect.value = currentListIndex; + updateListForm(); +} + +function updateListForm() { + const list = lists[currentListIndex]; + listName.value = list.name; + listBg.value = list.background; + listFg.value = list.foreground; + listActive.checked = list.active; +} + +function renderWords() { + const list = lists[currentListIndex]; + wordList.innerHTML = list.words.map((w, i) => ` +
+ + + + + +
+ `).join(""); +} + +listSelect.onchange = () => { + currentListIndex = +listSelect.value; + renderWords(); + updateListForm(); +}; + +document.getElementById("newListBtn").onclick = () => { + lists.push({ + id: Date.now(), + name: chrome.i18n.getMessage("new_list_name"), + background: "#ffff00", + foreground: "#000000", + active: true, + words: [] + }); + currentListIndex = lists.length - 1; + save(); +}; + +document.getElementById("deleteListBtn").onclick = () => { + if (confirm(chrome.i18n.getMessage("confirm_delete_list"))) { + lists.splice(currentListIndex, 1); + currentListIndex = Math.max(0, currentListIndex - 1); + save(); + } +}; + +listName.oninput = () => { lists[currentListIndex].name = listName.value; save(); }; +listBg.oninput = () => { lists[currentListIndex].background = listBg.value; save(); }; +listFg.oninput = () => { lists[currentListIndex].foreground = listFg.value; save(); }; +listActive.onchange = () => { lists[currentListIndex].active = listActive.checked; save(); }; + +document.getElementById("addWordsBtn").onclick = () => { + const words = bulkPaste.value.split(/\n+/).map(w => w.trim()).filter(Boolean); + const list = lists[currentListIndex]; + for (const w of words) list.words.push({ wordStr: w, background: "", foreground: "", active: true }); + bulkPaste.value = ""; + save(); +}; + +document.getElementById("selectAllBtn").onclick = () => { + wordList.querySelectorAll("input[type=checkbox]").forEach(cb => cb.checked = true); +}; + +document.getElementById("deleteSelectedBtn").onclick = () => { + if (confirm(chrome.i18n.getMessage("confirm_delete_words"))) { + const list = lists[currentListIndex]; + const toDelete = [...wordList.querySelectorAll("input[type=checkbox]:checked")].map(cb => +cb.dataset.index); + lists[currentListIndex].words = list.words.filter((_, i) => !toDelete.includes(i)); + save(); + } +}; + +document.getElementById("disableSelectedBtn").onclick = () => { + const list = lists[currentListIndex]; + wordList.querySelectorAll("input[type=checkbox]:checked").forEach(cb => list.words[+cb.dataset.index].active = false); + save(); +}; + +document.getElementById("enableSelectedBtn").onclick = () => { + const list = lists[currentListIndex]; + wordList.querySelectorAll("input[type=checkbox]:checked").forEach(cb => list.words[+cb.dataset.index].active = true); + save(); +}; + +wordList.addEventListener("input", e => { + const index = e.target.dataset.wordEdit ?? e.target.dataset.bgEdit ?? e.target.dataset.fgEdit; + if (e.target.dataset.wordEdit != null) lists[currentListIndex].words[index].wordStr = e.target.value; + if (e.target.dataset.bgEdit != null) lists[currentListIndex].words[index].background = e.target.value; + if (e.target.dataset.fgEdit != null) lists[currentListIndex].words[index].foreground = e.target.value; + save(); +}); + +wordList.addEventListener("change", e => { + if (e.target.dataset.activeEdit != null) { + lists[currentListIndex].words[e.target.dataset.activeEdit].active = e.target.checked; + save(); + } +}); + + +const exportBtn = document.getElementById("exportBtn"); +exportBtn.onclick = () => { + const blob = new Blob([JSON.stringify(lists, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "highlight-lists.json"; + a.click(); + URL.revokeObjectURL(url); +}; + +const importBtn = document.getElementById("importBtn"); +importBtn.onclick = () => importInput.click(); + +importInput.onchange = e => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = e => { + try { + const data = JSON.parse(e.target.result); + if (Array.isArray(data)) { + lists = data; + currentListIndex = 0; + save(); + } + } catch (err) { + alert(chrome.i18n.getMessage("invalid_json_error")); + } + }; + reader.readAsText(file); +}; + +// Localize the page +function localizePage() { + // Localize all elements with a data-i18n attribute + const elements = document.querySelectorAll('[data-i18n]'); + elements.forEach(element => { + const message = element.dataset.i18n; + const localizedText = chrome.i18n.getMessage(message); + if (localizedText) { + if (element.tagName === 'INPUT' && element.hasAttribute('placeholder')) { + element.placeholder = localizedText; + } else { + element.textContent = localizedText; + } + } + }); +} + +// Call this function when the page loads +document.addEventListener('DOMContentLoaded', localizePage); + +load(); \ No newline at end of file diff --git a/storage.js b/storage.js new file mode 100644 index 0000000..88f33f7 --- /dev/null +++ b/storage.js @@ -0,0 +1,8 @@ +export async function getLists() { + const { lists } = await chrome.storage.local.get("lists"); + return lists || []; +} + +export async function saveLists(lists) { + await chrome.storage.local.set({ lists }); +} diff --git a/utils.js b/utils.js new file mode 100644 index 0000000..e69de29