Action

Draft Version Diff

Posted by FlohGro, Last update 2 days ago

Draft Version Diff

created by @FlohGro / more on my Blog

Uses a nicely styled HTML preview to show a diff of different versions from the current draft.

At the top the version to diff against can be selected.

The chunks of the diff can be reverted individually so the end result is stored in the draft when saving.


If you find this useful and want to support me you can donate or buy me a coffee

Steps

  • script

    // Draft Version Diff
    // created by @FlohGro
    // Visual git-style diff between draft versions with interactive per-chunk accept/revert.
    
    function showDraftDiff() {
      var currentContent = draft.content;
      var versions = draft.versions;
    
      if (!versions || versions.length === 0) {
        app.displayWarningMessage("No previous versions available for this draft.");
        return;
      }
    
      // Build version data for embedding in HTML
      var maxVersions = Math.min(versions.length, 20);
      var versionData = [];
      for (var v = 0; v < maxVersions; v++) {
        var ts = versions[v].createdAt;
        var label = ts.toLocaleDateString() + " " + ts.toLocaleTimeString();
        if (v === 0) label += " (previous)";
        versionData.push({ label: label, content: versions[v].content });
      }
    
      // Serialize data, escaping </script> sequences
      var versionsJson = JSON.stringify(versionData).replace(/<\//g, "<\\/");
      var currentJson = JSON.stringify(currentContent).replace(/<\//g, "<\\/");
    
      // Build the full HTML page.
      // CSS is built via concatenation; the <script> block uses a clean
      // multi-line string so the client-side JS is readable and debuggable.
      var css = ':root{'
        + '--bg:#111118;--surface:#1e1e2e;--surface-inline:#313244;'
        + '--text:#D9D9D9;--text-muted:#9399b2;--text-dim:#585b70;'
        + '--accent:#89B4FA;--border:#313244;'
        + '--add-text:#a6e3a1;--add-bg:rgba(166,227,161,0.08);'
        + '--rm-text:#f38ba8;--rm-bg:rgba(243,139,168,0.08);'
        + '--selection-bg:#89B4FA;--selection-text:#111118;'
        + '}'
        + '*{margin:0;padding:0;box-sizing:border-box}'
        + '::selection{background:var(--selection-bg);color:var(--selection-text)}'
        + 'body{font-family:"JetBrains Mono",Consolas,Menlo,monospace;background:var(--bg);color:var(--text);padding:0 0 70px;-webkit-font-smoothing:antialiased;font-size:13px;line-height:1.5}'
        + '.hdr{position:sticky;top:0;z-index:100;background:var(--surface);border-bottom:1px solid var(--accent);padding:10px 16px}'
        + '.hdr-top{display:flex;justify-content:space-between;align-items:center}'
        + '.hdr-title{font-size:14px;font-weight:700;color:var(--accent)}'
        + '.hdr-stats{font-size:11px;color:var(--text-muted)}'
        + '.hdr-select{margin-top:8px}'
        + '.ver-select{font-family:inherit;font-size:12px;background:var(--surface-inline);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 10px;width:100%;-webkit-appearance:none;appearance:none}'
        + '.bottom-bar{position:fixed;bottom:0;left:0;right:0;z-index:100;background:var(--surface);border-top:1px solid var(--border);padding:12px 16px;display:flex;justify-content:center;gap:16px}'
        + '.btn{font-family:-apple-system,system-ui,Helvetica,Arial,sans-serif;font-size:14px;font-weight:600;padding:10px 24px;border-radius:6px;border:1px solid rgba(27,31,35,.15);cursor:pointer;-webkit-tap-highlight-color:rgba(0,0,0,0.1);-webkit-appearance:none;appearance:none;touch-action:manipulation;display:inline-block;text-align:center;line-height:20px}'
        + '.btn-save{background:var(--add-text);color:var(--bg)}'
        + '.btn-save:active{opacity:0.7}'
        + '.btn-cancel{background:#666;color:#fff}'
        + '.btn-cancel:active{opacity:0.7}'
        + '.diff{padding:8px 0}'
        + '.no-diff{text-align:center;color:var(--text-muted);padding:40px 16px;font-size:14px}'
        + '.line{display:flex;padding:1px 16px 1px 12px;min-height:1.5em}'
        + '.line.ctx{color:var(--text-dim)}'
        + '.line.add{background:var(--add-bg);color:var(--add-text)}'
        + '.line.rm{background:var(--rm-bg);color:var(--rm-text)}'
        + '.ln{min-width:36px;text-align:right;color:var(--text-dim);opacity:0.4;margin-right:8px;flex-shrink:0;user-select:none;-webkit-user-select:none;font-size:11px;line-height:1.8}'
        + '.pfx{width:16px;flex-shrink:0;text-align:center;font-weight:700;user-select:none;-webkit-user-select:none}'
        + '.line.add .pfx{color:var(--add-text)}'
        + '.line.rm .pfx{color:var(--rm-text)}'
        + '.code{flex:1;min-width:0;white-space:pre-wrap;word-break:break-all;tab-size:4}'
        + '.sep{text-align:center;color:var(--text-dim);font-size:11px;padding:6px;background:rgba(255,255,255,0.015);border-top:1px solid rgba(255,255,255,0.04);border-bottom:1px solid rgba(255,255,255,0.04);letter-spacing:0.5px}'
        + '.chunk{border-left:3px solid var(--accent);margin:2px 0}'
        + '.chunk-bar{display:flex;align-items:center;gap:8px;padding:4px 12px;background:rgba(137,180,250,0.05)}'
        + '.chunk-label{font-size:10px;font-weight:700;color:var(--text-dim);letter-spacing:0.5px;text-transform:uppercase;margin-right:auto}'
        + '.cbtn{font-family:inherit;font-size:10px;font-weight:700;padding:3px 10px;border-radius:4px;border:1px solid transparent;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:all 0.15s}'
        + '.cbtn:not(.active){background:transparent;color:var(--text-dim);opacity:0.5}'
        + '.cbtn-keep.active{background:rgba(166,227,161,0.15);color:var(--add-text);border-color:rgba(166,227,161,0.3);opacity:1}'
        + '.cbtn-rev.active{background:rgba(243,139,168,0.15);color:var(--rm-text);border-color:rgba(243,139,168,0.3);opacity:1}'
        + '.chunk.reverted{border-left-color:var(--rm-text)}'
        + '.chunk.reverted .chunk-bar{background:rgba(243,139,168,0.05)}'
        + '.chunk.reverted .line.add{opacity:0.3;text-decoration:line-through}'
        + '.chunk.reverted .line.rm{opacity:1;text-decoration:none}'
        // Inline word-level diff styles
        + '.line.mod{color:var(--text)}'
        + '.line.mod .pfx{color:var(--accent)}'
        + '.w-rm{background:#3d1f1f;color:#f38ba8;text-decoration:line-through;border-radius:2px;padding:0 1px}'
        + '.w-add{background:#1f3d2a;color:#a6e3a1;border-radius:2px;padding:0 1px}'
        + '.chunk.reverted .line.mod .w-add{opacity:0.3;text-decoration:line-through}'
        + '.chunk.reverted .line.mod .w-rm{opacity:1;text-decoration:none}';
    
      // Client-side JavaScript as a clean readable block.
      // Uses single quotes for all JS strings so no escaped-quote issues.
      var jsCode = [
        "var versions = " + versionsJson + ";",
        "var currentContent = " + currentJson + ";",
        "var ops = [];",
        "var totalChunks = 0;",
        "var states = {};",
        "",
        "function computeDiff(oldText, newText) {",
        "  var oldL = oldText.split('\\n');",
        "  var newL = newText.split('\\n');",
        "  var m = oldL.length, n = newL.length;",
        "  if (m * n > 4000000) return null;",
        "  var dp = [];",
        "  for (var i = 0; i <= m; i++) { dp[i] = []; for (var j = 0; j <= n; j++) dp[i][j] = 0; }",
        "  for (var i = 1; i <= m; i++) {",
        "    for (var j = 1; j <= n; j++) {",
        "      if (oldL[i-1] === newL[j-1]) dp[i][j] = dp[i-1][j-1] + 1;",
        "      else dp[i][j] = dp[i][j-1] > dp[i-1][j] ? dp[i][j-1] : dp[i-1][j];",
        "    }",
        "  }",
        "  var result = [];",
        "  var i = m, j = n;",
        "  while (i > 0 || j > 0) {",
        "    if (i > 0 && j > 0 && oldL[i-1] === newL[j-1]) {",
        "      result.unshift({t:'eq', l:oldL[i-1], o:i, n:j}); i--; j--;",
        "    } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {",
        "      result.unshift({t:'add', l:newL[j-1], o:0, n:j}); j--;",
        "    } else {",
        "      result.unshift({t:'rm', l:oldL[i-1], o:i, n:0}); i--;",
        "    }",
        "  }",
        "  return result;",
        "}",
        "",
        "function assignChunks(opsList) {",
        "  var cid = 0, inCh = false;",
        "  for (var k = 0; k < opsList.length; k++) {",
        "    if (opsList[k].t !== 'eq') {",
        "      if (!inCh) inCh = true;",
        "      opsList[k].c = cid;",
        "    } else {",
        "      if (inCh) { cid++; inCh = false; }",
        "      opsList[k].c = -1;",
        "    }",
        "  }",
        "  if (inCh) cid++;",
        "  return cid;",
        "}",
        "",
        "function esc(s) {",
        "  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');",
        "}",
        "",
        "// Word-level LCS diff for modified lines (rm+add pairs)",
        "function wordDiff(oldStr, newStr) {",
        "  var oldW = oldStr.split(/(\\s+)/);",
        "  var newW = newStr.split(/(\\s+)/);",
        "  var m = oldW.length, n = newW.length;",
        "  var dp = [];",
        "  for (var i = 0; i <= m; i++) { dp[i] = []; for (var j = 0; j <= n; j++) dp[i][j] = 0; }",
        "  for (var i = 1; i <= m; i++) {",
        "    for (var j = 1; j <= n; j++) {",
        "      if (oldW[i-1] === newW[j-1]) dp[i][j] = dp[i-1][j-1] + 1;",
        "      else dp[i][j] = dp[i][j-1] > dp[i-1][j] ? dp[i][j-1] : dp[i-1][j];",
        "    }",
        "  }",
        "  var wops = [];",
        "  var i = m, j = n;",
        "  while (i > 0 || j > 0) {",
        "    if (i > 0 && j > 0 && oldW[i-1] === newW[j-1]) {",
        "      wops.unshift({t:'eq', w:oldW[i-1]}); i--; j--;",
        "    } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {",
        "      wops.unshift({t:'add', w:newW[j-1]}); j--;",
        "    } else {",
        "      wops.unshift({t:'rm', w:oldW[i-1]}); i--;",
        "    }",
        "  }",
        "  var html = '';",
        "  for (var w = 0; w < wops.length; w++) {",
        "    var wo = wops[w];",
        "    if (wo.t === 'eq') html += esc(wo.w);",
        "    else if (wo.t === 'rm') html += '<span class=\"w-rm\">' + esc(wo.w) + '<\\/span>';",
        "    else html += '<span class=\"w-add\">' + esc(wo.w) + '<\\/span>';",
        "  }",
        "  return html;",
        "}",
        "",
        "// Pair rm+add lines within each chunk for inline word diff",
        "function pairModifiedLines() {",
        "  var chunkOps = {};",
        "  for (var i = 0; i < ops.length; i++) {",
        "    if (ops[i].c >= 0) {",
        "      if (!chunkOps[ops[i].c]) chunkOps[ops[i].c] = [];",
        "      chunkOps[ops[i].c].push(i);",
        "    }",
        "  }",
        "  for (var cid in chunkOps) {",
        "    var indices = chunkOps[cid];",
        "    var rms = [], adds = [];",
        "    for (var j = 0; j < indices.length; j++) {",
        "      if (ops[indices[j]].t === 'rm') rms.push(indices[j]);",
        "      else if (ops[indices[j]].t === 'add') adds.push(indices[j]);",
        "    }",
        "    var pairs = Math.min(rms.length, adds.length);",
        "    for (var p = 0; p < pairs; p++) {",
        "      ops[rms[p]].pairedWith = adds[p];",
        "      ops[rms[p]].isPairedRm = true;",
        "      ops[adds[p]].isPairedAdd = true;",
        "    }",
        "  }",
        "}",
        "",
        "function renderDiff() {",
        "  var addC = 0, rmC = 0;",
        "  for (var i = 0; i < ops.length; i++) {",
        "    if (ops[i].t === 'add') addC++;",
        "    else if (ops[i].t === 'rm') rmC++;",
        "  }",
        "  var statsEl = document.getElementById('stats');",
        "  statsEl.innerHTML = '<span style=\"color:var(--add-text)\">+' + addC + (addC !== 1 ? ' additions' : ' addition') + '</span>'",
        "    + ' \\u00a0 '",
        "    + '<span style=\"color:var(--rm-text)\">\\u2212' + rmC + (rmC !== 1 ? ' deletions' : ' deletion') + '</span>'",
        "    + ' \\u00a0 '",
        "    + '<span>' + totalChunks + (totalChunks !== 1 ? ' chunks' : ' chunk') + '</span>';",
        "  if (totalChunks === 0) {",
        "    document.getElementById('diffContainer').innerHTML = '<div class=\"no-diff\">No differences found between versions.</div>';",
        "    return;",
        "  }",
        "  var CTX = 3;",
        "  var cRanges = [];",
        "  var idx = 0;",
        "  while (idx < ops.length) {",
        "    if (ops[idx].t !== 'eq') {",
        "      var s = idx;",
        "      while (idx < ops.length && ops[idx].t !== 'eq') idx++;",
        "      cRanges.push([s, idx]);",
        "    } else { idx++; }",
        "  }",
        "  var vis = [];",
        "  for (var r = 0; r < cRanges.length; r++) {",
        "    vis.push([Math.max(0, cRanges[r][0] - CTX), Math.min(ops.length, cRanges[r][1] + CTX)]);",
        "  }",
        "  var mg = [vis[0]];",
        "  for (var r = 1; r < vis.length; r++) {",
        "    var la = mg[mg.length - 1];",
        "    if (vis[r][0] <= la[1]) la[1] = Math.max(la[1], vis[r][1]);",
        "    else mg.push(vis[r]);",
        "  }",
        "  var h = '';",
        "  var pCid = -2;",
        "  for (var r = 0; r < mg.length; r++) {",
        "    if (r === 0 && mg[r][0] > 0) {",
        "      h += '<div class=\"sep\">\\u00b7\\u00b7\\u00b7 ' + mg[r][0] + ' unchanged line' + (mg[r][0] !== 1 ? 's' : '') + ' \\u00b7\\u00b7\\u00b7<\\/div>';",
        "    } else if (r > 0) {",
        "      var gap = mg[r][0] - mg[r-1][1];",
        "      if (gap > 0) h += '<div class=\"sep\">\\u00b7\\u00b7\\u00b7 ' + gap + ' unchanged line' + (gap !== 1 ? 's' : '') + ' \\u00b7\\u00b7\\u00b7<\\/div>';",
        "    }",
        "    for (var k = mg[r][0]; k < mg[r][1]; k++) {",
        "      var op = ops[k];",
        "      if (op.c >= 0 && op.c !== pCid) {",
        "        if (pCid >= 0) h += '<\\/div>';",
        "        h += '<div class=\"chunk\" id=\"chunk-' + op.c + '\">'",
        "          + '<div class=\"chunk-bar\">'",
        "          + '<span class=\"chunk-label\">Chunk ' + (op.c + 1) + '<\\/span>'",
        "          + '<button class=\"cbtn cbtn-keep active\" id=\"keep-' + op.c + '\" data-chunk=\"' + op.c + '\" data-action=\"keep\">\\u2713 Keep<\\/button>'",
        "          + '<button class=\"cbtn cbtn-rev\" id=\"rev-' + op.c + '\" data-chunk=\"' + op.c + '\" data-action=\"revert\">\\u21a9 Revert<\\/button>'",
        "          + '<\\/div>';",
        "        pCid = op.c;",
        "      } else if (op.c < 0 && pCid >= 0) { h += '<\\/div>'; pCid = -2; }",
        "      // Skip paired add ops (rendered with their rm partner)",
        "      if (op.isPairedAdd) continue;",
        "      var ln = op.t === 'rm' ? (op.o || '') : (op.t === 'add' ? (op.n || '') : (op.o || ''));",
        "      if (op.isPairedRm) {",
        "        // Inline word diff: show single line with highlighted changes",
        "        var addOp = ops[op.pairedWith];",
        "        var wdHtml = wordDiff(op.l, addOp.l);",
        "        var modLn = addOp.n || '';",
        "        h += '<div class=\"line mod\"><span class=\"ln\">' + modLn + '<\\/span><span class=\"pfx\">\\u2261<\\/span><span class=\"code\">' + wdHtml + '<\\/span><\\/div>';",
        "      } else {",
        "        var lc = op.l.length === 0 ? ' ' : esc(op.l);",
        "        if (op.t === 'eq') {",
        "          h += '<div class=\"line ctx\"><span class=\"ln\">' + ln + '<\\/span><span class=\"pfx\"> <\\/span><span class=\"code\">' + lc + '<\\/span><\\/div>';",
        "        } else if (op.t === 'rm') {",
        "          h += '<div class=\"line rm\"><span class=\"ln\">' + ln + '<\\/span><span class=\"pfx\">\\u2212<\\/span><span class=\"code\">' + lc + '<\\/span><\\/div>';",
        "        } else {",
        "          h += '<div class=\"line add\"><span class=\"ln\">' + ln + '<\\/span><span class=\"pfx\">+<\\/span><span class=\"code\">' + lc + '<\\/span><\\/div>';",
        "        }",
        "      }",
        "    }",
        "    if (r === mg.length - 1 && mg[r][1] < ops.length) {",
        "      if (pCid >= 0) { h += '<\\/div>'; pCid = -2; }",
        "      var trail = ops.length - mg[r][1];",
        "      h += '<div class=\"sep\">\\u00b7\\u00b7\\u00b7 ' + trail + ' unchanged line' + (trail !== 1 ? 's' : '') + ' \\u00b7\\u00b7\\u00b7<\\/div>';",
        "    }",
        "  }",
        "  if (pCid >= 0) h += '<\\/div>';",
        "  document.getElementById('diffContainer').innerHTML = h;",
        "}",
        "",
        "function runDiff(versionIdx) {",
        "  var oldText = versions[versionIdx].content;",
        "  var result = computeDiff(oldText, currentContent);",
        "  if (!result) {",
        "    document.getElementById('diffContainer').innerHTML = '<div class=\"no-diff\">Document too large for diff.<\\/div>';",
        "    ops = []; totalChunks = 0; states = {};",
        "    return;",
        "  }",
        "  ops = result;",
        "  totalChunks = assignChunks(ops);",
        "  pairModifiedLines();",
        "  states = {};",
        "  for (var i = 0; i < totalChunks; i++) states[i] = 'keep';",
        "  renderDiff();",
        "}",
        "",
        "var sel = document.getElementById('versionSelect');",
        "for (var i = 0; i < versions.length; i++) {",
        "  var opt = document.createElement('option');",
        "  opt.value = i;",
        "  opt.text = 'Compare against: ' + versions[i].label;",
        "  sel.appendChild(opt);",
        "}",
        "sel.addEventListener('change', function() { runDiff(parseInt(sel.value)); });",
        "runDiff(0);",
        "",
        "document.addEventListener('click', function(e) {",
        "  var btn = e.target.closest('[data-chunk]');",
        "  if (btn) {",
        "    var id = parseInt(btn.getAttribute('data-chunk'));",
        "    var action = btn.getAttribute('data-action');",
        "    states[id] = action;",
        "    var ch = document.getElementById('chunk-' + id);",
        "    var kb = document.getElementById('keep-' + id);",
        "    var rb = document.getElementById('rev-' + id);",
        "    if (action === 'keep') {",
        "      ch.classList.remove('reverted');",
        "      kb.classList.add('active'); rb.classList.remove('active');",
        "    } else {",
        "      ch.classList.add('reverted');",
        "      kb.classList.remove('active'); rb.classList.add('active');",
        "    }",
        "    return;",
        "  }",
        "  if (e.target.closest('#saveBtn')) {",
        "    Drafts.send('save', JSON.stringify(states));",
        "    Drafts.send('vIdx', String(document.getElementById('versionSelect').value));",
        "    Drafts.continue();",
        "    return;",
        "  }",
        "  if (e.target.closest('#cancelBtn')) {",
        "    Drafts.cancel();",
        "  }",
        "});"
      ].join("\n");
    
      var html = '<!DOCTYPE html><html><head><meta charset="utf-8">'
        + '<meta name="viewport" content="width=device-width, initial-scale=1">'
        + '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap">'
        + '<style>' + css + '</style></head><body>'
        + '<div class="hdr">'
        + '<div class="hdr-top">'
        + '<div class="hdr-title">Draft Diff</div>'
        + '<div class="hdr-stats" id="stats"></div>'
        + '</div>'
        + '<div class="hdr-select">'
        + '<select class="ver-select" id="versionSelect"></select>'
        + '</div>'
        + '</div>'
        + '<div class="diff" id="diffContainer"></div>'
        + '<div class="bottom-bar">'
        + '<button class="btn btn-cancel" id="cancelBtn">Cancel</button>'
        + '<button class="btn btn-save" id="saveBtn">Save Changes</button>'
        + '</div>'
        + '<script>' + jsCode + '<\/script>'
        + '</body></html>';
    
      // Show preview
      var preview = HTMLPreview.create();
      preview.hideInterface = true;
      preview.show(html);
    
      // Process result after preview dismisses
      var savedStatesJson = context.previewValues["save"];
      if (savedStatesJson) {
        var chunkStates = JSON.parse(savedStatesJson);
        var vIdx = parseInt(context.previewValues["vIdx"] || "0");
        if (vIdx < 0 || vIdx >= maxVersions) vIdx = 0;
        var oldContent = versions[vIdx].content;
    
        // Recompute diff server-side (same LCS algorithm)
        var oldLines = oldContent.split("\n");
        var newLines = currentContent.split("\n");
        var m = oldLines.length, n = newLines.length;
        var dp = [];
        for (var i = 0; i <= m; i++) { dp[i] = []; for (var j = 0; j <= n; j++) dp[i][j] = 0; }
        for (var i = 1; i <= m; i++) {
          for (var j = 1; j <= n; j++) {
            if (oldLines[i - 1] === newLines[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
            else dp[i][j] = dp[i][j - 1] > dp[i - 1][j] ? dp[i][j - 1] : dp[i - 1][j];
          }
        }
        var ops = [];
        var i = m, j = n;
        while (i > 0 || j > 0) {
          if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
            ops.unshift({ t: "eq", l: oldLines[i - 1] }); i--; j--;
          } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
            ops.unshift({ t: "add", l: newLines[j - 1] }); j--;
          } else {
            ops.unshift({ t: "rm", l: oldLines[i - 1] }); i--;
          }
        }
    
        // Assign chunk IDs
        var chunkId = 0, inChange = false;
        for (var k = 0; k < ops.length; k++) {
          if (ops[k].t !== "eq") {
            if (!inChange) inChange = true;
            ops[k].c = chunkId;
          } else {
            if (inChange) { chunkId++; inChange = false; }
            ops[k].c = -1;
          }
        }
        if (inChange) chunkId++;
    
        // Reassemble text using chunk states
        var lines = [];
        for (var k = 0; k < ops.length; k++) {
          var op = ops[k];
          if (op.t === "eq") {
            lines.push(op.l);
          } else if (op.t === "add" && chunkStates[String(op.c)] === "keep") {
            lines.push(op.l);
          } else if (op.t === "rm" && chunkStates[String(op.c)] === "revert") {
            lines.push(op.l);
          }
        }
    
        draft.content = lines.join("\n");
        draft.update();
        editor.load(draft);
        editor.activate();
        app.displaySuccessMessage("Draft updated with selected changes.");
      }
    }
    
    showDraftDiff()

Options

  • After Success Nothing
    Notification Error
    Log Level Info
Items available in the Drafts Directory are uploaded by community members. Use appropriate caution reviewing downloaded items before use.