Расширение «Моё время в Битрикс24»
Часы по задачам за месяц и остаток до нормы, через REST от сессии браузера. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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(); // служебная страница браузера, туда скрипт не пустят
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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 = '<b>Моё время</b> <input type="month" id="mytime-m" value="' + now.getFullYear() + '-' + pad(now.getMonth() + 1) + '">' +
|
||||||
|
' <button id="mytime-x" style="float:right">✕</button><div id="mytime-out"></div>';
|
||||||
|
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', '<CREATED_DATE': next + '-01T00:00:00' },
|
||||||
|
SELECT: ['ID', 'TASK_ID', 'SECONDS', 'CREATED_DATE'],
|
||||||
|
PARAMS: { NAV_PARAMS: { nPageSize: 50, iNumPage: page } },
|
||||||
|
});
|
||||||
|
// повторная проверка месяца на случай, если портал понял фильтр по дате иначе
|
||||||
|
for (const it of items) if (it.CREATED_DATE.startsWith(ym)) perTask[it.TASK_ID] = (perTask[it.TASK_ID] || 0) + Number(it.SECONDS);
|
||||||
|
out.textContent = 'Считаю… страница ' + page;
|
||||||
|
if (items.length < 50) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = Object.keys(perTask);
|
||||||
|
const titles = {};
|
||||||
|
for (let i = 0; i < ids.length; i += 50) {
|
||||||
|
const r = await call('tasks.task.list', { filter: { ID: ids.slice(i, i + 50) }, select: ['ID', 'TITLE'] });
|
||||||
|
for (const t of r.tasks) titles[t.id] = t.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = ids.reduce((a, id) => a + perTask[id], 0);
|
||||||
|
const left = norm * 3600 - total;
|
||||||
|
const normRow = !norm ? '' : '<tr><td></td><td>' + (left > 0
|
||||||
|
? 'До нормы ' + norm + ' ч осталось <b>' + hm(left) + '</b>'
|
||||||
|
: 'Норма ' + norm + ' ч выполнена' + (left < 0 ? ', сверх нормы ' + hm(-left) : '')) + '</td></tr>';
|
||||||
|
out.innerHTML = '<table style="border-collapse:collapse;margin-top:10px">' +
|
||||||
|
ids.sort((a, b) => perTask[b] - perTask[a]).map(id =>
|
||||||
|
'<tr><td style="text-align:right;padding:2px 12px 2px 0;white-space:nowrap">' + hm(perTask[id]) + '</td>' +
|
||||||
|
'<td><a href="/company/personal/user/' + me + '/tasks/task/view/' + id + '/">#' + id + '</a> ' + esc(titles[id] || '') + '</td></tr>'
|
||||||
|
).join('') +
|
||||||
|
'<tr style="font-weight:bold;border-top:2px solid #999"><td style="text-align:right;padding:4px 12px 0 0">' + hm(total) +
|
||||||
|
'</td><td>Итого за ' + ym + '</td></tr>' + normRow + '</table>';
|
||||||
|
} catch (e) {
|
||||||
|
out.textContent = 'Ошибка: ' + e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('mytime-m').onchange = run;
|
||||||
|
run();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Моё время — настройки</title>
|
||||||
|
<style>
|
||||||
|
body { font: 14px/1.6 system-ui, sans-serif; margin: 20px; color: #333; min-width: 420px; }
|
||||||
|
label { display: block; margin-bottom: 12px; }
|
||||||
|
input { display: block; width: 100%; box-sizing: border-box; padding: 4px 6px; margin-top: 2px; }
|
||||||
|
small { color: #777; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<label>Адрес портала
|
||||||
|
<input id="portal" placeholder="https://company.bitrix24.ru/">
|
||||||
|
<small>Если открыта другая вкладка, кнопка откроет портал. Можно оставить пустым.</small>
|
||||||
|
</label>
|
||||||
|
<label>Норма часов в месяц
|
||||||
|
<input id="norm" type="number" min="0" step="0.5" placeholder="20">
|
||||||
|
<small>Покажу, сколько осталось до нормы. 0 или пусто — не показывать.</small>
|
||||||
|
</label>
|
||||||
|
<button id="save">Сохранить</button> <span id="status"></span>
|
||||||
|
<script src="options.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 = 'Сохранено';
|
||||||
|
};
|
||||||
@@ -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<', 'осталось <b>5:05</b>', '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');
|
||||||
Reference in New Issue
Block a user