Skip to content

Commit

Permalink
Handle suggested changes (#213)
Browse files Browse the repository at this point in the history
This adds handling for suggested changes in a Google Doc. Before, if there were suggestions in the text, we'd show them in a totally undifferentiated way right next to the original text, which could make things appear totally garbled.

This also adds a settings UI for choosing how to convert the suggestions: you can show them marked up as insertions and deletions in the markdown, accept them and render them as normal text, or reject them and render the markdown as if they weren’t there (the default). Settings are saved in localStorage so it’s consistent across uses and page loads.

I've been meaning to add settings like this for a while, and a followup change will add the ability to choose how heading IDs are handled. This also has infrastructure we can re-use in the future for linking to bookmarks in the text.

Fixes #194.
  • Loading branch information
Mr0grog authored Aug 14, 2024
1 parent 8309201 commit 95b40d8
Show file tree
Hide file tree
Showing 17 changed files with 2,030 additions and 43 deletions.
14 changes: 13 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
}

#app-header h1 {
margin: 0;
margin: 0 0 0.5em;
padding: 0;
}

Expand Down Expand Up @@ -129,6 +129,18 @@
<body>
<header id="app-header">
<h1>Convert Google Docs to Markdown</h1>

<form id="settings">
<span title="Settings">⚙️</span>
<label>
Suggested changes:
<select name="suggestions">
<option value="show">Show suggestions</option>
<option value="accept">Accept/use suggestions</option>
<option value="reject">Reject/ignore suggestions</option>
</select>
</label>
</form>
</header>

<main>
Expand Down
59 changes: 47 additions & 12 deletions index.js → lib-ui/index.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,34 @@
import { convertDocsHtmlToMarkdown } from './lib/convert.js';
import { convertDocsHtmlToMarkdown, defaultOptions } from '../lib/convert.js';
import { settings as currentSettings } from './settings.js';
import debug from 'debug';

const SLICE_CLIP_MEDIA_TYPE =
'application/x-vnd.google-docs-document-slice-clip';

const log = debug('app:index:debug');

const settingsForm = document.getElementById('settings');
const inputElement = document.getElementById('input');
const outputElement = document.getElementById('output');
const inputInstructions = document.querySelector('#input-area .instructions');
const outputInstructions = document.querySelector('#output-area .instructions');

function convert() {
convertDocsHtmlToMarkdown(
inputElement.innerHTML,
latestSliceClip,
currentSettings.getAll()
)
.then((markdown) => {
outputElement.value = markdown;
outputInstructions.style.display = markdown.trim() ? 'none' : '';
})
.catch((error) => {
console.error(error);
outputInstructions.style.display = '';
});
}

// Hold most recently pasted Slice Clip (the Google Docs internal copy/paste
// format) globally so we can re-use it if the user hand-edits the input.
let latestSliceClip = null;
Expand All @@ -36,19 +54,9 @@ inputElement.addEventListener('input', () => {
const hasContent = !!inputElement.textContent;
inputInstructions.style.display = hasContent ? 'none' : '';

convertDocsHtmlToMarkdown(inputElement.innerHTML, latestSliceClip)
.then((markdown) => {
outputElement.value = markdown;
outputInstructions.style.display = markdown.trim() ? 'none' : '';
})
.catch((error) => {
console.error(error);
outputInstructions.style.display = '';
});
convert();
});

window.convertDocsHtmlToMarkdown = convertDocsHtmlToMarkdown;

const copyButton = document.getElementById('copy-button');
if (navigator.clipboard && navigator.clipboard.writeText) {
copyButton.style.display = '';
Expand Down Expand Up @@ -84,3 +92,30 @@ if (window.URL && window.File) {
}
});
}

function updateSettingsForm() {
for (const input of settingsForm.querySelectorAll('input,select')) {
const value = currentSettings.get(input.name);
if (value != null) {
if (input.type === 'checkbox') {
input.checked = value;
} else {
input.value = value;
}
}
}
}

settingsForm.addEventListener('change', (event) => {
let value = event.target.value;
if (event.target.type === 'checkbox') {
value = event.target.checked;
}
currentSettings.set(event.target.name, value);
convert();
});

window.convertDocsHtmlToMarkdown = convertDocsHtmlToMarkdown;
currentSettings.setAll(defaultOptions, { save: false });
currentSettings.load();
updateSettingsForm();
46 changes: 46 additions & 0 deletions lib-ui/settings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export const settings = {
get(key) {
return this._data[key];
},

getAll() {
return Object.assign({}, this._data);
},

set(key, value) {
this._data[key] = value;
this.save();
},

setAll(newData, { save = true } = {}) {
Object.assign(this._data, newData);
if (save) {
this.save();
}
},

toJSON() {
return this.getAll();
},

save() {
const serialized = JSON.stringify(this);
try {
window.localStorage.setItem(this._storageKey, serialized);
} catch (error) {
console.error('Error saving settings:', error);
}
},

load() {
try {
const serialized = window.localStorage.getItem(this._storageKey);
this.setAll(JSON.parse(serialized), { save: false });
} catch (_error) {
// OK: there might be nothing saved.
}
},

_storageKey: 'gdoc2md.options',
_data: {},
};
14 changes: 12 additions & 2 deletions lib/convert.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@ import rehype2remarkWithSpaces from './rehype-to-remark-with-spaces.js';
import remarkGfm from 'remark-gfm';
import stringify from 'remark-stringify';
import { unified } from 'unified';
// import logTree from './log-tree.js';

/** @typedef {import("mdast-util-to-markdown").State} MdastState */
/** @typedef {import("unist").Node} UnistNode */
/** @typedef {import("hast-util-to-mdast").Handle} Handle */

export const defaultOptions = {
// TODO: the headings options are not hooked up yet.
headingIdValue: 'text',
headingIdSyntax: 'html',
suggestions: 'reject',
};

/** @type {Handle} */
function preserveTagAndConvertContents(state, node, _parent) {
return [
Expand Down Expand Up @@ -54,12 +62,13 @@ function doubleBlankLinesBeforeHeadings(previous, next, _parent, _state) {
const processor = unified()
.use(parse)
.use(fixGoogleHtml)
// .use(require('./lib/log-tree').default)
// .use(logTree)
.use(rehype2remarkWithSpaces, {
handlers: {
// Preserve sup/sub markup; most Markdowns have no markup for it.
sub: preserveTagAndConvertContents,
sup: preserveTagAndConvertContents,
ins: preserveTagAndConvertContents,
h1: headingWithId,
h2: headingWithId,
h3: headingWithId,
Expand Down Expand Up @@ -108,13 +117,14 @@ function parseGdocsSliceClip(raw) {
return data;
}

export function convertDocsHtmlToMarkdown(html, rawSliceClip) {
export function convertDocsHtmlToMarkdown(html, rawSliceClip, options) {
const sliceClip = rawSliceClip ? parseGdocsSliceClip(rawSliceClip) : null;
return processor
.process({
value: html,
data: {
sliceClip,
options: { ...defaultOptions, ...options },
},
})
.then((result) => result.value);
Expand Down
92 changes: 70 additions & 22 deletions lib/fix-google-html.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { visitParents } from 'unist-util-visit-parents';
import GithubSlugger from 'github-slugger';
import debug from 'debug';
import { resolveNodeStyle } from './css.js';
import {
sliceClipText,
rangesForSuggestions,
replaceRangesInTree,
} from './slice-clip.js';

const log = debug('app:fix-google-html:debug');

Expand Down Expand Up @@ -63,7 +68,7 @@ const blockElements = new Set([

// These elements convert to Markdown nodes that can't start or end with spaces.
// For example, you can't start emphasis with a space: `This * is emphasized*`.
const spaceSensitiveElements = new Set(['em', 'strong']);
const spaceSensitiveElements = new Set(['em', 'strong', 'ins', 'del']);

const isList = (node) => node.tagName === 'ul' || node.tagName === 'ol';
const isStyled = (node) => node.type === 'element' && node.properties.style;
Expand All @@ -72,6 +77,7 @@ const isSpaceSensitive = (node) =>
node && spaceSensitiveElements.has(node.tagName);
const isCell = (node) => node.tagName === 'th' || node.tagName === 'td';
const isAnchor = (node) => node.tagName === 'a';
const isSuggestion = (n) => n.properties?.['data-suggestion-id'] != null;

const spaceAtStartPattern = /^(\s+)/;
const spaceAtEndPattern = /(\s+)$/;
Expand Down Expand Up @@ -642,29 +648,65 @@ function fixInternalLinks(node, sliceClip) {
});

// Update any links to the headings.
visit(
node,
(n) => n.tagName === 'a',
(node, _index, _parent) => {
let url;
try {
url = new URL(node.properties.href);
} catch (_error) {
return;
}
visit(node, isAnchor, (node, _index, _parent) => {
let url;
try {
url = new URL(node.properties.href);
} catch (_error) {
return;
}

if (url.host === 'docs.google.com') {
const internalHeadingId = url.hash.match(
/^#heading=([a-z0-9.]+)$/
)?.[1];
log('Updating link to %o', internalHeadingId);
const newId = headingIdMap.get(internalHeadingId);
if (newId) {
node.properties.href = `#${newId}`;
}
if (url.host === 'docs.google.com') {
const internalHeadingId = url.hash.match(/^#heading=([a-z0-9.]+)$/)?.[1];
log('Updating link to %o', internalHeadingId);
const newId = headingIdMap.get(internalHeadingId);
if (newId) {
node.properties.href = `#${newId}`;
}
}
);
});
}

/**
* Find suggested changes in the text and mark them with appropriate DOM nodes.
* Copying from Google Docs includes the suggestions in the text with no markup,
* so you can't tell what's been suggested for removal or insertion. However,
* the Slice Clip data includes the location of the suggestions.
* @param {RehypeNode} node
* @param {*} sliceClip
*/
function markSuggestions(node, sliceClip) {
if (!sliceClip) return;

const ranges = [
...rangesForSuggestions(sliceClip, 'insertion'),
...rangesForSuggestions(sliceClip, 'deletion'),
];

replaceRangesInTree(sliceClipText(sliceClip), ranges, node, (range, text) => {
return {
type: 'element',
tagName: range.type === 'insertion' ? 'ins' : 'del',
properties: { 'data-suggestion-id': range.suggestionId },
children: [{ type: 'text', value: text }],
};
});
}

function formatSuggestions(tree, options) {
if (!['accept', 'reject'].includes(options.suggestions)) return;

visit(tree, isSuggestion, (node, index, parent) => {
if (
(options.suggestions === 'accept' && node.tagName === 'ins') ||
(options.suggestions === 'reject' && node.tagName === 'del')
) {
parent.children.splice(index, 1, ...node.children);
} else {
parent.children.splice(index, 1);
}
return [CONTINUE, index];
});
}

/**
Expand All @@ -674,6 +716,12 @@ function fixInternalLinks(node, sliceClip) {
*/
export default function fixGoogleHtml() {
return (tree, _file) => {
// Update the tree with data from a Google Docs Slice Clip.
markSuggestions(tree, _file.data.sliceClip);
fixInternalLinks(tree, _file.data.sliceClip);

// Generalized tree cleanup.
formatSuggestions(tree, _file.data.options);
unInlineStyles(tree);
createCodeBlocks(tree);
moveSpaceOutsideSensitiveChildren(tree);
Expand All @@ -683,7 +731,7 @@ export default function fixGoogleHtml() {
moveLinebreaksOutsideOfAnchors(tree);
removeLineBreaksBeforeBlocks(tree);
fixChecklists(tree);
fixInternalLinks(tree, _file.data.sliceClip);

return tree;
};
}
Loading

0 comments on commit 95b40d8

Please sign in to comment.