commit c701f074834249bfe5a66fbf8232ec6a0695a470 Author: Alex Cube Date: Mon Oct 26 01:14:06 2026 +0300 Расширение «Моё время в Битрикс24» Часы по задачам за месяц и остаток до нормы, через REST от сессии браузера. Co-Authored-By: Claude Opus 5.5 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d8ff7f7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Alexey Yakubov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6256bbe --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# Моё время в Битрикс24 + +Расширение для Chrome, Яндекс Браузера и других браузеров на Chromium. По кнопке +показывает, сколько часов вы затратили на задачи Битрикс24 за месяц, с разбивкой +по задачам, и сколько осталось до месячной нормы. + +Работает с правами обычного сотрудника: не нужны ни администратор портала, ни +вебхуки, ни подписка на Маркет. Расширение обращается к REST API портала от +имени вашей сессии в браузере и только читает ваши записи затраченного времени. + +## Установка + +1. Скачайте репозиторий (или архив) и распакуйте. +2. Откройте `chrome://extensions/` (в Яндекс Браузере `browser://extensions/`), + включите «Режим разработчика». +3. «Загрузить распакованное расширение» → папка `extension`. +4. Откроются настройки: адрес портала (необязательно) и норма часов в месяц. + +## Как пользоваться + +Откройте портал, нажмите значок расширения. Повторное нажатие закрывает окно. +Месяц можно переключить. Время учитывается по записям «Затраченное время» в +задачах: пока таймер идёт, запись ещё не создана и в сумму не попадает. + +## Проверка + +``` +node test/test_mytime.mjs +``` + +Прогоняет `mytime.js` на поддельном портале: постраничная загрузка, сумма по +задачам, отсев чужого месяца, строка нормы. + +## Лицензия + +MIT diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..0af7efc --- /dev/null +++ b/extension/background.js @@ -0,0 +1,16 @@ +chrome.runtime.onInstalled.addListener(({ reason }) => { + if (reason === 'install') chrome.runtime.openOptionsPage(); +}); + +chrome.action.onClicked.addListener(async tab => { + const cfg = await chrome.storage.sync.get({ portal: '', norm: 0 }); + if (cfg.portal && !(tab.url || '').startsWith(cfg.portal)) return chrome.tabs.create({ url: cfg.portal }); + const target = { tabId: tab.id }; + try { + // MAIN — чтобы скрипту были видны BX и сессия портала + await chrome.scripting.executeScript({ target, world: 'MAIN', func: c => { window.myTimeConfig = c; }, args: [cfg] }); + await chrome.scripting.executeScript({ target, world: 'MAIN', files: ['mytime.js'] }); + } catch { + chrome.runtime.openOptionsPage(); // служебная страница браузера, туда скрипт не пустят + } +}); diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..9bf8345 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,10 @@ +{ + "manifest_version": 3, + "name": "Моё время в Битрикс24", + "version": "1.1", + "description": "Сколько часов я отработал по задачам за месяц и сколько осталось до нормы", + "permissions": ["activeTab", "scripting", "storage"], + "background": { "service_worker": "background.js" }, + "action": { "default_title": "Моё время" }, + "options_page": "options.html" +} diff --git a/extension/mytime.js b/extension/mytime.js new file mode 100644 index 0000000..e288bf0 --- /dev/null +++ b/extension/mytime.js @@ -0,0 +1,76 @@ +(async () => { + const old = document.getElementById('mytime'); + if (old) { old.remove(); return; } + if (!window.BX || !BX.bitrix_sessid) { alert('Открой портал Битрикс24 и войди в него'); return; } + + const norm = (window.myTimeConfig || {}).norm || 0; + const call = async (method, params) => { + const r = await fetch('/rest/' + method + '.json?sessid=' + BX.bitrix_sessid(), { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params), + }); + const d = await r.json(); + if (d.error) throw d.error + ' ' + (d.error_description || ''); + return d.result; + }; + const pad = n => String(n).padStart(2, '0'); + const hm = s => Math.floor(s / 3600) + ':' + pad(Math.floor(s % 3600 / 60)); + const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + + const box = document.createElement('div'); + box.id = 'mytime'; + box.style.cssText = 'position:fixed;top:70px;right:20px;z-index:99999;background:#fff;padding:16px 20px;' + + 'box-shadow:0 4px 24px rgba(0,0,0,.25);border-radius:8px;max-height:80vh;overflow:auto;font:14px/1.5 sans-serif;color:#333'; + const now = new Date(); + box.innerHTML = 'Моё время ' + + '
'; + document.body.append(box); + const out = document.getElementById('mytime-out'); + document.getElementById('mytime-x').onclick = () => box.remove(); + + async function run() { + const ym = document.getElementById('mytime-m').value; + const [y, m] = ym.split('-').map(Number); + const next = m === 12 ? (y + 1) + '-01' : y + '-' + pad(m + 1); + out.textContent = 'Считаю…'; + try { + const me = (await call('user.current', {})).ID; + const perTask = {}; + for (let page = 1; ; page++) { + const items = await call('task.elapseditem.getlist', { + ORDER: { ID: 'asc' }, + FILTER: { USER_ID: me, '>=CREATED_DATE': ym + '-01T00:00:00', ' a + perTask[id], 0); + const left = norm * 3600 - total; + const normRow = !norm ? '' : '' + (left > 0 + ? 'До нормы ' + norm + ' ч осталось ' + hm(left) + '' + : 'Норма ' + norm + ' ч выполнена' + (left < 0 ? ', сверх нормы ' + hm(-left) : '')) + ''; + out.innerHTML = '' + + ids.sort((a, b) => perTask[b] - perTask[a]).map(id => + '' + + '' + ).join('') + + '' + normRow + '
' + hm(perTask[id]) + '#' + id + ' ' + esc(titles[id] || '') + '
' + hm(total) + + 'Итого за ' + ym + '
'; + } catch (e) { + out.textContent = 'Ошибка: ' + e; + } + } + document.getElementById('mytime-m').onchange = run; + run(); +})(); diff --git a/extension/options.html b/extension/options.html new file mode 100644 index 0000000..757adb8 --- /dev/null +++ b/extension/options.html @@ -0,0 +1,25 @@ + + + + +Моё время — настройки + + + + + + + + + diff --git a/extension/options.js b/extension/options.js new file mode 100644 index 0000000..61429f0 --- /dev/null +++ b/extension/options.js @@ -0,0 +1,23 @@ +const portal = document.getElementById('portal'); +const norm = document.getElementById('norm'); +const status = document.getElementById('status'); + +chrome.storage.sync.get({ portal: '', norm: 0 }, cfg => { + portal.value = cfg.portal; + norm.value = cfg.norm || ''; +}); + +document.getElementById('save').onclick = async () => { + let p = portal.value.trim(); + if (p) { + try { + p = new URL(p.includes('://') ? p : 'https://' + p).origin + '/'; + } catch { + status.textContent = 'Адрес портала не похож на адрес'; + return; + } + } + await chrome.storage.sync.set({ portal: p, norm: Math.max(0, Number(norm.value) || 0) }); + portal.value = p; + status.textContent = 'Сохранено'; +}; diff --git a/test/test_mytime.mjs b/test/test_mytime.mjs new file mode 100644 index 0000000..195482d --- /dev/null +++ b/test/test_mytime.mjs @@ -0,0 +1,45 @@ +// Прогон mytime.js на поддельных DOM, BX и REST: node test/test_mytime.mjs +import fs from 'node:fs'; + +const els = {}; +const el = id => (els[id] ??= { id, value: id === 'mytime-m' ? '2026-10' : '', textContent: '', innerHTML: '', style: {}, remove() {} }); +globalThis.window = globalThis; +globalThis.alert = m => { throw new Error('alert: ' + m); }; +globalThis.document = { + getElementById: id => (id === 'mytime' ? null : el(id)), + createElement: () => ({ style: {}, set innerHTML(v) {}, remove() {} }), + body: { append() {} }, +}; +globalThis.BX = { bitrix_sessid: () => 'S' }; +globalThis.myTimeConfig = { norm: 20 }; + +// 120 записей: 60 по задаче 1 (по 10 мин), 59 по задаче 2 (по 5 мин), одна сентябрьская — должна отсеяться +const items = []; +for (let i = 0; i < 60; i++) items.push({ TASK_ID: '1', SECONDS: '600', CREATED_DATE: '2026-10-05T10:00:00+03:00' }); +for (let i = 0; i < 59; i++) items.push({ TASK_ID: '2', SECONDS: '300', CREATED_DATE: '2026-10-06T10:00:00+03:00' }); +items.push({ TASK_ID: '3', SECONDS: '9999', CREATED_DATE: '2026-09-30T23:00:00+03:00' }); + +const calls = []; +globalThis.fetch = async (url, opt) => { + const method = url.match(/rest\/(.+)\.json/)[1]; + const p = JSON.parse(opt.body); + calls.push(method); + let result; + if (method === 'user.current') result = { ID: '7' }; + else if (method === 'task.elapseditem.getlist') { + if (p.FILTER.USER_ID !== '7') throw new Error('USER_ID not passed'); + const n = p.PARAMS.NAV_PARAMS; + result = items.slice((n.iNumPage - 1) * n.nPageSize, n.iNumPage * n.nPageSize); + } else if (method === 'tasks.task.list') result = { tasks: p.filter.ID.map(id => ({ id, title: 'T<' + id + '>' })) }; + return { json: async () => ({ result }) }; +}; + +eval(fs.readFileSync(new URL('../extension/mytime.js', import.meta.url), 'utf8')); +await new Promise(r => setTimeout(r, 50)); + +const html = els['mytime-out'].innerHTML; +const must = ['>10:00<', '>4:55<', '>14:55<', 'осталось 5:05', 'T<1>', '/user/7/tasks/task/view/1/']; +for (const s of must) if (!html.includes(s)) throw new Error('missing ' + s + '\n' + html); +if (html.includes('#3')) throw new Error('September record counted'); +if (calls.filter(c => c === 'task.elapseditem.getlist').length !== 3) throw new Error('pages: ' + calls); +console.log('ok');