Script to Archive Odoo Project
Script JS :
// Odoo Offline Archiver v4 — paste into the browser console while logged in to Odoo.
// v3 (rev 2's full chatter + v2's embedded images, parallel downloads, archive.json)
// with every time shown in GMT+7 (WIB). Odoo stores times in UTC; archive.json keeps
// them exactly as Odoo gives them (UTC) so the hosted viewer can convert them too.
// Produces one ZIP: index.html (offline viewer) · archive.json · attachments/ · missing-attachments.csv
(async () => {
// ---------------------------------------------------------------
// CONFIG — edit these before running if needed
// ---------------------------------------------------------------
const CONFIG = {
PROJECT_ID: null, // e.g. 12 = export one project only, null = all projects
INCLUDE_ARCHIVED: false, // true = also export archived tasks
TASK_LIMIT: 5000, // max tasks to export
CHATTER_PER_TASK: 1000, // max chatter messages per task
CHATTER_CONCURRENCY: 4, // tasks whose chatter is fetched at the same time
FILE_CONCURRENCY: 6, // files downloaded at the same time
SKIP_FILES: false, // true = text only (no attachments), much faster
TIMEZONE_OFFSET_HOURS: 7, // times in index.html + ZIP name: 7 = GMT+7 (WIB), 8 = WITA, 9 = WIT
};
const TZ_HOURS = Number(CONFIG.TIMEZONE_OFFSET_HOURS) || 0;
const TZ_LABEL = 'GMT' + (TZ_HOURS >= 0 ? '+' : '') + TZ_HOURS;
// ── Progress overlay ──────────────────────────────────────────────────────
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:20px;right:20px;background:#0f172a;color:#f8fafc;padding:16px 22px;border-radius:10px;z-index:999999;box-shadow:0 10px 25px rgba(0,0,0,0.4);font-family:sans-serif;font-size:13px;min-width:320px;max-width:420px;border:1px solid #334155;';
overlay.innerHTML = '<strong style="font-size:14px;color:#38bdf8;">📦 Odoo Offline Archiver v4</strong><span style="margin-left:6px;color:#64748b;font-size:11px;">times in ' + TZ_LABEL + '</span><div id="odoo-status" style="margin-top:8px;color:#94a3b8;">Loading JSZip...</div><div style="margin-top:6px;color:#64748b;font-size:11px;">Keep this tab open until the ZIP downloads.</div>';
document.body.appendChild(overlay);
const setStatus = (txt) => { const el = document.getElementById('odoo-status'); if (el) el.innerText = txt; };
const escText = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const leaveGuard = (e) => { e.preventDefault(); e.returnValue = ''; };
window.addEventListener('beforeunload', leaveGuard);
try {
// ── Load JSZip ──────────────────────────────────────────────────────────
await new Promise((resolve, reject) => {
if (window.JSZip) return resolve();
const s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
s.onload = resolve;
s.onerror = () => {
const s2 = document.createElement('script');
s2.src = 'https://unpkg.com/jszip@3.10.1/dist/jszip.min.js';
s2.onload = resolve; s2.onerror = () => reject(new Error('Could not load JSZip (blocked by the network?)'));
document.head.appendChild(s2);
};
document.head.appendChild(s);
});
// ── Helpers ─────────────────────────────────────────────────────────────
const call = async (url, params) => {
const res = await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params })
});
if (!res.ok) throw new Error(url + ' HTTP ' + res.status);
const json = await res.json();
if (json.error) throw new Error((json.error.data && json.error.data.message) || json.error.message);
return json.result;
};
const rpc = (model, method, args, kwargs = {}) => call('/web/dataset/call_kw', { model, method, args, kwargs });
const pool = async (items, size, fn) => {
let next = 0;
const worker = async () => { while (next < items.length) { const i = next++; await fn(items[i], i); } };
await Promise.all(Array.from({ length: Math.max(1, Math.min(size, items.length)) }, worker));
};
// Odoo 18 sends HTML as ["markup", "<p>..</p>"]
const markup = (v) => (Array.isArray(v) && v[0] === 'markup') ? v[1] : (typeof v === 'string' ? v : '');
const m2oName = (v) => Array.isArray(v) ? v[1] : '';
const safeName = (s) => String(s || 'file').replace(/[/\\?%*:|"<>]/g, '_');
const zip = new JSZip();
const attachFolder = zip.folder('attachments');
// ── 1. Tasks ────────────────────────────────────────────────────────────
setStatus('1/5 Fetching tasks & dates...');
const domain = CONFIG.PROJECT_ID ? [['project_id', '=', CONFIG.PROJECT_ID]] : [];
const tasks = (await rpc('project.task', 'search_read', [domain], {
fields: ['id', 'name', 'project_id', 'stage_id', 'description', 'write_date', 'create_date', 'date_last_stage_update'],
limit: CONFIG.TASK_LIMIT,
order: 'id asc',
context: CONFIG.INCLUDE_ARCHIVED ? { active_test: false } : {}
})) || [];
if (!tasks.length) throw new Error('No tasks found. Are you logged in, and is PROJECT_ID correct?');
const taskIds = tasks.map(t => t.id);
// ── 2. Chatter with stage/field tracking (same endpoint as the Odoo UI) ─
const normaliseThread = (result) => {
const store = result.data || result; // Odoo 18 puts records in a store
const index = (key) => { const m = {}; (Array.isArray(store[key]) ? store[key] : []).forEach(x => { m[x.id] = x; }); return m; };
const storeMsgs = index('mail.message');
const partners = index('res.partner');
const list = (result.messages || []).map(m => (m && typeof m === 'object') ? m : storeMsgs[m]).filter(Boolean);
return list.map(m => {
const ref = m.author || m.author_id;
let authorId = 0, author = '';
if (Array.isArray(ref)) { authorId = ref[0]; author = ref[1]; }
else if (ref && typeof ref === 'object') { authorId = ref.id || 0; author = ref.name || (partners[ref.id] && partners[ref.id].name) || ''; }
else if (typeof ref === 'number') { authorId = ref; author = (partners[ref] && partners[ref].name) || ''; }
author = author || m.email_from || 'System';
const attIds = (m.attachments || m.attachment_ids || []).map(a => (a && typeof a === 'object') ? a.id : a).filter(Boolean);
const trackingValues = (m.trackingValues || []).map(tv => ({
fieldName: tv.fieldName || '',
changedField: tv.changedField || (tv.fieldInfo && tv.fieldInfo.changedField) || tv.fieldName || '',
oldValue: { value: tv.oldValue && tv.oldValue.value != null ? tv.oldValue.value : null },
newValue: { value: tv.newValue && tv.newValue.value != null ? tv.newValue.value : null }
}));
return {
id: m.id, author, author_id: [authorId, author], date: m.date || '',
body: markup(m.body), subtype: m2oName(m.subtype_id) || m.subtype_description || '',
message_type: m.message_type || '', trackingValues, attachment_ids: attIds
};
});
};
const msgsByTask = {};
const fallbackIds = [];
let chatDone = 0;
await pool(taskIds, CONFIG.CHATTER_CONCURRENCY, async (id) => {
try {
const result = await call('/mail/thread/messages', { thread_model: 'project.task', thread_id: id, limit: CONFIG.CHATTER_PER_TASK });
msgsByTask[id] = normaliseThread(result || {});
} catch (e) { fallbackIds.push(id); }
chatDone++;
if (chatDone % 10 === 0 || chatDone === taskIds.length) setStatus(`2/5 Chatter & stage history: ${chatDone}/${taskIds.length} tasks`);
});
// Fallback for tasks the rich endpoint refused: plain mail.message (+ tracking if readable)
if (fallbackIds.length) {
setStatus(`2/5 Basic chatter for ${fallbackIds.length} tasks...`);
const msgs = await rpc('mail.message', 'search_read',
[[['model', '=', 'project.task'], ['res_id', 'in', fallbackIds]]],
{ fields: ['id', 'res_id', 'author_id', 'date', 'body', 'attachment_ids', 'subtype_id', 'message_type', 'tracking_value_ids'], order: 'date asc', limit: 100000 });
const tvById = {};
const tvIds = msgs.flatMap(m => m.tracking_value_ids || []);
if (tvIds.length) {
const base = ['id', 'old_value_char', 'new_value_char', 'old_value_integer', 'new_value_integer', 'old_value_datetime', 'new_value_datetime'];
for (const extra of [['field_id'], ['field', 'field_desc']]) { // Odoo 17+ / Odoo 16
try { (await rpc('mail.tracking.value', 'read', [tvIds, base.concat(extra)])).forEach(tv => { tvById[tv.id] = tv; }); break; } catch (e) { /* try next layout */ }
}
}
const tvVal = (tv, side) => tv[side + '_value_char'] || tv[side + '_value_datetime'] || (tv[side + '_value_integer'] ? String(tv[side + '_value_integer']) : null);
msgs.forEach(m => {
const author = m2oName(m.author_id) || 'System';
const trackingValues = (m.tracking_value_ids || []).map(id => tvById[id]).filter(Boolean).map(tv => {
const label = m2oName(tv.field_id) || tv.field_desc || tv.field || '';
return {
fieldName: tv.field || (/stage/i.test(label) ? 'stage_id' : ''),
changedField: label.replace(/\s*\(.*\)$/, ''),
oldValue: { value: tvVal(tv, 'old') }, newValue: { value: tvVal(tv, 'new') }
};
});
(msgsByTask[m.res_id] = msgsByTask[m.res_id] || []).push({
id: m.id, author, author_id: m.author_id || [0, author], date: m.date || '', body: markup(m.body),
subtype: m2oName(m.subtype_id), message_type: m.message_type || '', trackingValues,
attachment_ids: m.attachment_ids || []
});
});
}
// ── 3. Attachments: task files + every file/image referenced anywhere ───
setStatus('3/5 Scanning attachments...');
const attFields = ['id', 'name', 'res_id', 'mimetype', 'file_size'];
const attById = {};
(await rpc('ir.attachment', 'search_read',
[[['res_model', '=', 'project.task'], ['res_id', 'in', taskIds]]], { fields: attFields })
).forEach(a => { attById[a.id] = a; });
const referenced = new Map(); // attachment id -> Set(task ids)
const refer = (id, taskId) => { id = Number(id); if (!referenced.has(id)) referenced.set(id, new Set()); referenced.get(id).add(taskId); };
const scanHtml = (html, taskId) => { if (html) for (const m of html.matchAll(/\/web\/(?:image|content)\/(\d+)/g)) refer(m[1], taskId); };
tasks.forEach(t => scanHtml(markup(t.description), t.id));
Object.entries(msgsByTask).forEach(([tid, msgs]) => msgs.forEach(m => {
scanHtml(m.body, Number(tid));
m.attachment_ids.forEach(id => refer(id, Number(tid)));
}));
const unknown = [...referenced.keys()].filter(id => !attById[id]);
if (unknown.length) {
setStatus(`3/5 Looking up ${unknown.length} embedded files...`);
try {
(await rpc('ir.attachment', 'search_read', [[['id', 'in', unknown]]], { fields: attFields }))
.forEach(a => { attById[a.id] = a; });
} catch (e) { console.warn('Embedded attachment lookup failed:', e); }
// Still unknown (pasted from another record, or no read access): try downloading anyway
unknown.filter(id => !attById[id]).forEach(id => {
attById[id] = { id, name: 'embedded_' + id, res_id: [...referenced.get(id)][0], mimetype: false, _guess: true };
});
}
const allAtts = Object.values(attById);
// ── 4. Download files (parallel) ────────────────────────────────────────
const extFor = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg', 'application/pdf': 'pdf' };
const failed = {};
if (!CONFIG.SKIP_FILES) {
let done = 0;
await pool(allAtts, CONFIG.FILE_CONCURRENCY, async (a) => {
try {
const r = await fetch(`/web/content/${a.id}?download=true`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const type = (r.headers.get('content-type') || '').split(';')[0].trim();
if (a._guess) {
if (type === 'text/html') throw new Error('not accessible');
a.mimetype = type || false;
if (extFor[type]) a.name += '.' + extFor[type];
}
const clean = `${a.id}_${safeName(a.name)}`;
attachFolder.file(clean, await r.blob());
a.local_path = `attachments/${clean}`;
} catch (err) {
failed[a.id] = String((err && err.message) || err);
}
done++;
const nFail = Object.keys(failed).length;
setStatus(`4/5 Downloading files [${done}/${allAtts.length}]${nFail ? ` — ${nFail} failed` : ''}`);
});
}
// ── 5. Build archive data ───────────────────────────────────────────────
setStatus('5/5 Building index.html + archive.json...');
const fixUrls = (html) => !html ? '' : html.replace(/\/web\/(?:image|content)\/(\d+)[^"']*/g,
(match, id) => (attById[id] && attById[id].local_path) || match);
const pub = (a) => ({ id: a.id, name: a.name, res_id: a.res_id, mimetype: a.mimetype, file_size: a.file_size, local_path: a.local_path });
const archiveData = tasks.map(t => ({
id: t.id,
title: t.name,
project: m2oName(t.project_id),
stage: Array.isArray(t.stage_id) ? t.stage_id[1] : 'Undefined Stage',
create_date: t.create_date || '',
last_stage_update: t.date_last_stage_update || '',
updated: t.write_date || '',
description: fixUrls(markup(t.description)) || '<em>No description</em>',
comments: (msgsByTask[t.id] || []).map(m => Object.assign({}, m, {
res_id: t.id,
body: fixUrls(m.body),
attachments: m.attachment_ids.map(id => attById[id]).filter(a => a && !a._guess).map(pub)
}))
}));
// Files the tasks point to but that are not in the ZIP
const missingRows = [];
if (!CONFIG.SKIP_FILES) {
referenced.forEach((taskSet, id) => {
const a = attById[id];
if (a && a.local_path) return;
taskSet.forEach(tid => missingRows.push([tid, id, a && !a._guess ? a.name : '', failed[id] || 'not found']));
});
missingRows.sort((x, y) => x[0] - y[0] || x[1] - y[1]);
}
const csvCell = (v) => /[",\n]/.test(String(v)) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v);
const fileCount = allAtts.filter(a => a.local_path).length;
// ── Offline index.html ──────────────────────────────────────────────────
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Odoo Project Archive (Offline)</title>
<style>
*{box-sizing:border-box;}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;flex-direction:column;height:100vh;background:#f8fafc;color:#1e293b;overflow:hidden;}
#top-nav{height:42px;background:#0f172a;color:#f8fafc;display:flex;align-items:center;justify-content:space-between;padding:0 15px;font-size:13px;flex-shrink:0;}
.nav-btn{background:#1e293b;border:1px solid #334155;color:#cbd5e1;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:12px;}
.nav-btn:hover{background:#334155;color:#fff;}
.nav-muted{color:#94a3b8;}
#workspace{flex:1;display:flex;overflow:hidden;}
#sidebar{width:380px;min-width:250px;max-width:600px;background:#fff;display:flex;flex-direction:column;height:100%;flex-shrink:0;border-right:1px solid #cbd5e1;}
#sidebar.hidden{display:none;}
#sidebar-controls{padding:10px 12px;border-bottom:1px solid #e2e8f0;display:flex;flex-direction:column;gap:8px;}
#search{width:100%;padding:7px 10px;border:1px solid #cbd5e1;border-radius:6px;font-size:13px;outline:none;}
#search:focus{border-color:#4f46e5;}
.ctrl-row{display:flex;align-items:center;gap:6px;font-size:12px;color:#64748b;}
#sort-select{padding:4px 6px;border:1px solid #cbd5e1;border-radius:5px;font-size:11px;background:#f8fafc;color:#334155;flex:1;cursor:pointer;}
#groups-container{overflow-y:auto;flex:1;}
.resizer{width:5px;cursor:col-resize;background:#e2e8f0;transition:background 0.2s;flex-shrink:0;}
.resizer:hover,.resizer.dragging{background:#4f46e5;}
.stage-group{border-bottom:1px solid #e2e8f0;}
.stage-header{padding:9px 12px;background:#f1f5f9;cursor:pointer;display:flex;align-items:center;justify-content:space-between;font-weight:700;font-size:12px;color:#334155;user-select:none;}
.stage-header:hover{background:#e2e8f0;}
.stage-arrow{transition:transform 0.2s;margin-right:6px;font-size:10px;display:inline-block;}
.stage-arrow.collapsed{transform:rotate(-90deg);}
.stage-count{font-weight:500;color:#64748b;font-size:11px;background:#e2e8f0;padding:1px 6px;border-radius:10px;}
.stage-task-list{list-style:none;margin:0;padding:0;}
.stage-task-list.collapsed{display:none;}
.task-item{padding:9px 12px 9px 22px;border-bottom:1px solid #f1f5f9;cursor:pointer;}
.task-item:hover{background:#f8fafc;}
.task-item.active{background:#e0e7ff;border-left:4px solid #4f46e5;}
.task-item-title{font-size:13px;font-weight:600;line-height:1.4;color:#0f172a;margin-bottom:3px;}
.task-item-meta{font-size:11px;color:#64748b;display:flex;flex-direction:column;gap:2px;}
#main-desc{flex:1;padding:25px 30px;overflow-y:auto;background:#fff;min-width:300px;}
.meta-badge{display:inline-block;padding:3px 8px;border-radius:4px;font-size:11px;font-weight:600;background:#e0e7ff;color:#3730a3;margin-right:8px;}
.meta-info{font-size:12px;color:#64748b;margin-bottom:20px;line-height:1.6;border-bottom:1px solid #f1f5f9;padding-bottom:15px;}
#chatter{width:450px;min-width:280px;max-width:800px;background:#f8fafc;padding:20px;overflow-y:auto;flex-shrink:0;border-left:1px solid #cbd5e1;}
#chatter.hidden{display:none;}
.chatter-header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #e2e8f0;padding-bottom:10px;margin-bottom:15px;}
.chatter-header h3{margin:0;font-size:15px;color:#0f172a;}
#chatter-count{font-size:12px;color:#64748b;margin-left:6px;}
.chatter-sort-btn{background:#fff;border:1px solid #cbd5e1;padding:4px 8px;border-radius:4px;font-size:11px;cursor:pointer;color:#475569;}
.chatter-sort-btn:hover{background:#f1f5f9;}
.comment-card{background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:14px;margin-bottom:12px;box-shadow:0 1px 2px rgba(0,0,0,0.03);}
.comment-card.stage-change{background:#fafafa;border-left:3px solid #8b5cf6;}
.comment-card.field-change{background:#fafafa;border-left:3px solid #3b82f6;}
.comment-author{font-weight:700;color:#0f172a;margin-bottom:2px;font-size:13px;}
.comment-date{font-size:11px;color:#64748b;margin-bottom:8px;}
.comment-body{font-size:13px;line-height:1.5;color:#334155;}
.tracking-change{margin-top:8px;}
.tracking-row{display:flex;align-items:center;gap:6px;margin-bottom:4px;flex-wrap:wrap;}
.tracking-field{color:#64748b;font-size:11px;text-transform:uppercase;letter-spacing:0.4px;min-width:60px;}
.tracking-old{color:#dc2626;background:#fef2f2;padding:2px 7px;border-radius:4px;font-size:12px;font-weight:500;text-decoration:line-through;}
.tracking-arrow{color:#4f46e5;font-weight:bold;font-size:14px;}
.tracking-new{color:#16a34a;background:#f0fdf4;padding:2px 7px;border-radius:4px;font-size:12px;font-weight:500;}
.comment-attachment-img{max-width:100%;max-height:400px;border:1px solid #cbd5e1;border-radius:6px;margin-top:8px;display:block;}
.file-badge{display:inline-flex;align-items:center;padding:6px 10px;background:#f1f5f9;border:1px solid #cbd5e1;border-radius:4px;font-size:12px;text-decoration:none;color:#0f172a;font-weight:500;margin-top:6px;margin-right:6px;}
.file-badge:hover{background:#e2e8f0;}
img{max-width:100%;}
table{border-collapse:collapse;width:100%;margin:10px 0;font-size:12px;}
th,td{border:1px solid #cbd5e1;padding:6px 8px;}
th{background:#f1f5f9;}
</style>
</head>
<body>
<div id="top-nav">
<div style="display:flex;align-items:center;gap:10px;">
<button class="nav-btn" id="btn-toggle-sidebar" onclick="toggleSidebar()">◀ Hide Tasks</button>
<span><strong>Odoo Offline Archive</strong> <span class="nav-muted">(${archiveData.length} tasks · ${fileCount} files · times in ${TZ_LABEL})</span></span>
</div>
<button class="nav-btn" id="btn-toggle-chatter" onclick="toggleChatter()">Hide Chatter ▶</button>
</div>
<div id="workspace">
<div id="sidebar">
<div id="sidebar-controls">
<input type="text" id="search" placeholder="Search tasks by name, id, stage..." oninput="applyFilterAndSort()">
<div class="ctrl-row">
<span>Sort:</span>
<select id="sort-select" onchange="applyFilterAndSort()">
<option value="last_stage_desc">Last Stage Update (Newest first)</option>
<option value="last_stage_asc">Last Stage Update (Oldest first)</option>
<option value="create_desc">Date Created (Newest first)</option>
<option value="create_asc">Date Created (Oldest first)</option>
<option value="title_asc">Title (A - Z)</option>
<option value="id_desc">ID (Highest first)</option>
</select>
</div>
</div>
<div id="groups-container"></div>
</div>
<div class="resizer" id="resizer-left"></div>
<div id="main-desc">
<h2 id="task-title" style="margin-top:0;">Select a task</h2>
<div id="task-meta" class="meta-info"></div>
<div id="task-body"></div>
</div>
<div class="resizer" id="resizer-right"></div>
<div id="chatter">
<div class="chatter-header">
<div style="display:flex;align-items:center;">
<h3>Chatter & Comments</h3>
<span id="chatter-count"></span>
</div>
<button class="chatter-sort-btn" id="btn-chatter-sort" onclick="toggleChatterSort()">↓ Newest First</button>
</div>
<div id="comments-container"></div>
</div>
</div>
<script>
const tasks = ${JSON.stringify(archiveData).replace(/</g, '\\u003c')};
const container = document.getElementById('groups-container');
let currentTask = null;
let chatterOrder = 'newest_first';
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
function toggleSidebar(){
const sb=document.getElementById('sidebar'),r=document.getElementById('resizer-left'),btn=document.getElementById('btn-toggle-sidebar');
sb.classList.toggle('hidden');
r.style.display=sb.classList.contains('hidden')?'none':'block';
btn.innerText=sb.classList.contains('hidden')?'▶ Show Tasks':'◀ Hide Tasks';
}
function toggleChatter(){
const ch=document.getElementById('chatter'),r=document.getElementById('resizer-right'),btn=document.getElementById('btn-toggle-chatter');
ch.classList.toggle('hidden');
r.style.display=ch.classList.contains('hidden')?'none':'block';
btn.innerText=ch.classList.contains('hidden')?'Show Chatter ◀':'Hide Chatter ▶';
}
function makeResizable(resizer,target,isLeft){
let startX,startW;
resizer.addEventListener('mousedown',function(e){
startX=e.clientX; startW=target.getBoundingClientRect().width;
resizer.classList.add('dragging');
const mv=function(e){const w=isLeft?startW+(e.clientX-startX):startW-(e.clientX-startX);if(w>200&&w<900)target.style.width=w+'px';};
const up=function(){resizer.classList.remove('dragging');window.removeEventListener('mousemove',mv);window.removeEventListener('mouseup',up);};
window.addEventListener('mousemove',mv); window.addEventListener('mouseup',up);
});
}
makeResizable(document.getElementById('resizer-left'),document.getElementById('sidebar'),true);
makeResizable(document.getElementById('resizer-right'),document.getElementById('chatter'),false);
const TZ_H=${TZ_HOURS};
const TZ_LABEL='${TZ_LABEL}';
function pad2(n){return String(n).padStart(2,'0');}
function fmt(s){
if(!s) return 'N/A';
const m=/^([0-9]{4})-([0-9]{2})-([0-9]{2})[ T]([0-9]{2}):([0-9]{2})(?::([0-9]{2}))?/.exec(s);
if(!m) return s;
const d=new Date(Date.UTC(+m[1],+m[2]-1,+m[3],+m[4],+m[5],+(m[6]||0))+TZ_H*3600000);
return d.getUTCFullYear()+'-'+pad2(d.getUTCMonth()+1)+'-'+pad2(d.getUTCDate())+' '+pad2(d.getUTCHours())+':'+pad2(d.getUTCMinutes())+':'+pad2(d.getUTCSeconds());
}
function day(s){return s?fmt(s).split(' ')[0]:'N/A';}
function fmtVal(v){return (typeof v==='string'&&/^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$/.test(v))?fmt(v):v;}
function applyFilterAndSort(){
const q=document.getElementById('search').value.toLowerCase().trim();
const mode=document.getElementById('sort-select').value;
let filtered=tasks.filter(function(t){return t.title.toLowerCase().includes(q)||String(t.id).includes(q)||(t.stage&&t.stage.toLowerCase().includes(q));});
filtered.sort(function(a,b){
if(mode==='last_stage_desc') return (b.last_stage_update||'').localeCompare(a.last_stage_update||'');
if(mode==='last_stage_asc') return (a.last_stage_update||'').localeCompare(b.last_stage_update||'');
if(mode==='create_desc') return (b.create_date||'').localeCompare(a.create_date||'');
if(mode==='create_asc') return (a.create_date||'').localeCompare(b.create_date||'');
if(mode==='title_asc') return a.title.localeCompare(b.title);
if(mode==='id_desc') return b.id-a.id;
return 0;
});
renderGroups(filtered);
}
function renderGroups(items){
container.innerHTML='';
const groups={};
items.forEach(function(t){const s=t.stage||'No Stage';if(!groups[s])groups[s]=[];groups[s].push(t);});
let isFirst=true;
Object.keys(groups).forEach(function(stageName){
const stageTasks=groups[stageName];
const groupEl=document.createElement('div'); groupEl.className='stage-group';
const header=document.createElement('div'); header.className='stage-header';
header.innerHTML='<div><span class="stage-arrow">▼</span> '+esc(stageName)+'</div><span class="stage-count">'+stageTasks.length+'</span>';
const ul=document.createElement('ul'); ul.className='stage-task-list';
header.onclick=function(){const arrow=header.querySelector('.stage-arrow');const c=ul.classList.toggle('collapsed');arrow.classList.toggle('collapsed',c);};
stageTasks.forEach(function(t){
const li=document.createElement('li'); li.className='task-item';
li.innerHTML='<div class="task-item-title">#'+t.id+' '+esc(t.title)+'</div>'+
'<div class="task-item-meta">'+
'<span>🕒 Stage Update: '+day(t.last_stage_update)+'</span>'+
'<span>📅 Created: '+day(t.create_date)+'</span>'+
'</div>';
li.onclick=function(e){e.stopPropagation();selectTask(t,li);};
ul.appendChild(li);
if(isFirst){selectTask(t,li);isFirst=false;}
});
groupEl.appendChild(header); groupEl.appendChild(ul); container.appendChild(groupEl);
});
}
function selectTask(t,el){
currentTask=t;
document.querySelectorAll('.task-item').forEach(function(i){i.classList.remove('active');});
if(el) el.classList.add('active');
document.getElementById('task-title').innerText='#'+t.id+' '+t.title;
document.getElementById('task-meta').innerHTML=
'<span class="meta-badge">'+esc(t.stage)+'</span>'+
(t.project?'<strong>Project:</strong> '+esc(t.project)+' | ':'')+
'<strong>Created:</strong> '+esc(fmt(t.create_date))+' | '+
'<strong>Last Stage Update:</strong> '+esc(fmt(t.last_stage_update))+' | '+
'<strong>Last Modified:</strong> '+esc(fmt(t.updated))+
' <span style="color:#94a3b8">('+TZ_LABEL+')</span>';
document.getElementById('task-body').innerHTML=t.description;
renderChatter();
}
function toggleChatterSort(){
chatterOrder=chatterOrder==='newest_first'?'oldest_first':'newest_first';
document.getElementById('btn-chatter-sort').innerText=chatterOrder==='newest_first'?'↓ Newest First':'↑ Oldest First';
renderChatter();
}
function renderChatter(){
const el=document.getElementById('comments-container');
el.innerHTML='';
if(!currentTask||!currentTask.comments||currentTask.comments.length===0){
el.innerHTML='<p style="color:#94a3b8;font-size:13px;">No comments.</p>';
document.getElementById('chatter-count').innerText='';
return;
}
const sorted=currentTask.comments.slice().sort(function(a,b){
const cmp=(b.date||'').localeCompare(a.date||'');
return chatterOrder==='newest_first'?cmp:-cmp;
});
document.getElementById('chatter-count').innerText=' ('+sorted.length+')';
sorted.forEach(function(c){
const tvs=c.trackingValues||[];
const isStage=tvs.some(function(tv){return tv.fieldName==='stage_id';});
const isField=!isStage&&tvs.length>0;
const card=document.createElement('div');
card.className='comment-card'+(isStage?' stage-change':isField?' field-change':'');
let html='<div class="comment-author">'+esc(c.author)+'</div>'+
'<div class="comment-date">'+esc(fmt(c.date))+' '+TZ_LABEL+'</div>';
if(c.body) html+='<div class="comment-body">'+c.body+'</div>';
if(tvs.length>0){
html+='<div class="tracking-change">';
tvs.forEach(function(tv){
const oldVal=(tv.oldValue&&tv.oldValue.value!=null&&tv.oldValue.value!==false&&tv.oldValue.value!=='')?fmtVal(tv.oldValue.value):'(empty)';
const newVal=(tv.newValue&&tv.newValue.value!=null&&tv.newValue.value!==false&&tv.newValue.value!=='')?fmtVal(tv.newValue.value):'(empty)';
html+='<div class="tracking-row">'+
'<span class="tracking-field">'+esc(tv.changedField||tv.fieldName)+':</span>'+
'<span class="tracking-old">'+esc(oldVal)+'</span>'+
'<span class="tracking-arrow">→</span>'+
'<span class="tracking-new">'+esc(newVal)+'</span>'+
'</div>';
});
html+='</div>';
} else if(!c.body){
html+='<div class="comment-body" style="color:#94a3b8;font-style:italic;">Activity / Stage Change</div>';
}
if(c.attachments&&c.attachments.length>0){
html+='<div style="margin-top:8px;">';
c.attachments.forEach(function(att){
if(!att.local_path){html+='<span class="file-badge" style="color:#b45309;">⚠ '+esc(att.name)+' (not downloaded)</span> ';return;}
const isImg=(att.mimetype&&att.mimetype.startsWith('image/'))||/\\.(png|jpe?g|gif|webp)$/i.test(att.name);
if(isImg){
html+='<a href="'+esc(att.local_path)+'" target="_blank"><img class="comment-attachment-img" src="'+esc(att.local_path)+'" title="'+esc(att.name)+'"/></a>';
} else {
const icon=/\\.xlsx?$/i.test(att.name)?'📊':'📄';
html+='<a class="file-badge" href="'+esc(att.local_path)+'" download="'+esc(att.name)+'">'+icon+' '+esc(att.name)+'</a> ';
}
});
html+='</div>';
}
card.innerHTML=html;
el.appendChild(card);
});
}
applyFilterAndSort();
<\/script>
</body>
</html>`;
zip.file('index.html', html);
zip.file('archive.json', JSON.stringify(archiveData));
if (missingRows.length) {
zip.file('missing-attachments.csv', '\ufeff' + [['Task ID', 'File ID', 'File name', 'Reason']].concat(missingRows).map(r => r.map(csvCell).join(',')).join('\n'));
}
// ── Compress and download ZIP ───────────────────────────────────────────
setStatus('Compressing archive...');
const zipBlob = await zip.generateAsync({ type: 'blob' }, meta => setStatus(`Compressing: ${meta.percent.toFixed(0)}%`));
const a = document.createElement('a');
a.href = URL.createObjectURL(zipBlob);
a.download = `Odoo_Complete_Offline_Project_${new Date(Date.now() + TZ_HOURS * 3600000).toISOString().slice(0, 10)}.zip`;
document.body.appendChild(a); a.click(); a.remove();
const missingTasks = new Set(missingRows.map(r => r[0])).size;
overlay.innerHTML = '<strong style="color:#4ade80;font-size:14px;">✅ Archive Downloaded!</strong>' +
'<div style="margin-top:6px;color:#cbd5e1;">' + archiveData.length + ' tasks · ' + fileCount + ' files. Extract the ZIP and open <strong>index.html</strong>, or use “Open ZIP” in the hosted viewer.' +
(missingRows.length ? '<br><span style="color:#fbbf24">' + missingRows.length + (missingRows.length === 1 ? ' file' : ' files') + ' in ' + missingTasks + (missingTasks === 1 ? ' task' : ' tasks') + ' could not be saved — see missing-attachments.csv.</span>' : '') +
(fallbackIds.length ? '<br><span style="color:#94a3b8">' + fallbackIds.length + ' tasks used basic chatter (no field-change details).</span>' : '') +
'</div>';
} catch (err) {
console.error('Odoo Archiver v4 stopped:', err);
overlay.innerHTML = '<strong style="color:#f87171;font-size:14px;">❌ Archiver stopped</strong><div style="margin-top:6px;color:#cbd5e1;">' + escText((err && err.message) || err) + '</div>';
} finally {
window.removeEventListener('beforeunload', leaveGuard);
}
})();Attachments · 1 · 34 KB
- Created
- October 21, 2026 at 01:48 PM
- Last edited
- October 21, 2026 at 02:11 PM