← The Archive

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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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()">&#9664; 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 &#9654;</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 &amp; Comments</h3>
          <span id="chatter-count"></span>
        </div>
        <button class="chatter-sort-btn" id="btn-chatter-sort" onclick="toggleChatterSort()">&#8595; 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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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)+' &nbsp;|&nbsp; ':'')+
        '<strong>Created:</strong> '+esc(fmt(t.create_date))+' &nbsp;|&nbsp; '+
        '<strong>Last Stage Update:</strong> '+esc(fmt(t.last_stage_update))+' &nbsp;|&nbsp; '+
        '<strong>Last Modified:</strong> '+esc(fmt(t.updated))+
        ' &nbsp;<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