Keep the monday import going when an author or a board fails
Deploy Waters Boards to AWS / deploy (push) Successful in 1m28s
Deploy Waters Boards to AWS / deploy (push) Successful in 1m28s
The importer only creates accounts for active monday users. An update or
reply written by a deactivated staff member or an outside guest looked up
an account that didn't exist, bound undefined to SQLite, and stopped the
whole import ("Provided value cannot be bound to SQLite parameter 4").
Those posts now import with the author's name and no linked account.
A board that fails partway is now deleted and the import moves on, so the
next run retries it instead of skipping a half-imported board. A rejected
token or persistent rate limiting still stops the run. The import page
says "finished with errors" when some boards made it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
513067509c
commit
455322a7a4
@@ -263,7 +263,7 @@ function MondayImport() {
|
|||||||
</div>
|
</div>
|
||||||
<button class="btn primary" disabled=${!sel.length || status?.running} onClick=${start}>Import ${sel.length} board${sel.length === 1 ? '' : 's'}</button>` : null}
|
<button class="btn primary" disabled=${!sel.length || status?.running} onClick=${start}>Import ${sel.length} board${sel.length === 1 ? '' : 's'}</button>` : null}
|
||||||
${status && (status.running || status.log.length) ? html`<div class="import-log">
|
${status && (status.running || status.log.length) ? html`<div class="import-log">
|
||||||
<div class="row gap"><strong>${status.running ? 'Importing…' : status.error ? 'Import failed' : 'Import finished'}</strong>${status.running ? html`<${Spinner} />` : null}
|
<div class="row gap"><strong>${status.running ? 'Importing…' : status.error ? (status.counts.boards ? 'Import finished with errors' : 'Import failed') : 'Import finished'}</strong>${status.running ? html`<${Spinner} />` : null}
|
||||||
<span class="muted small">${status.counts.boards || 0} boards · ${status.counts.items || 0} items · ${status.counts.updates || 0} updates · ${status.counts.files || 0} files</span></div>
|
<span class="muted small">${status.counts.boards || 0} boards · ${status.counts.items || 0} items · ${status.counts.updates || 0} updates · ${status.counts.files || 0} files</span></div>
|
||||||
<pre>${status.log.slice(-200).join('\n')}</pre></div>` : null}
|
<pre>${status.log.slice(-200).join('\n')}</pre></div>` : null}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const UPLOADS = path.join(DATA_DIR, 'uploads');
|
|||||||
const status = { running: false, log: [], startedAt: null, finishedAt: null, error: null, counts: {} };
|
const status = { running: false, log: [], startedAt: null, finishedAt: null, error: null, counts: {} };
|
||||||
const log = (msg) => { status.log.push(`${new Date().toLocaleTimeString()} ${msg}`); if (status.log.length > 500) status.log.shift(); console.log('[import]', msg); };
|
const log = (msg) => { status.log.push(`${new Date().toLocaleTimeString()} ${msg}`); if (status.log.length > 500) status.log.shift(); console.log('[import]', msg); };
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
// Errors that would fail every remaining board the same way, so the import stops instead of moving on.
|
||||||
|
const fatal = (msg) => Object.assign(new Error(msg), { fatal: true });
|
||||||
|
|
||||||
async function gql(token, query, variables = {}, attempt = 0) {
|
async function gql(token, query, variables = {}, attempt = 0) {
|
||||||
const res = await fetch(API, {
|
const res = await fetch(API, {
|
||||||
@@ -26,13 +28,13 @@ async function gql(token, query, variables = {}, attempt = 0) {
|
|||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
const errs = body.errors || (body.error_message ? [{ message: body.error_message }] : null);
|
const errs = body.errors || (body.error_message ? [{ message: body.error_message }] : null);
|
||||||
if (res.status === 429 || (errs && /complexity|rate limit|budget/i.test(JSON.stringify(errs)))) {
|
if (res.status === 429 || (errs && /complexity|rate limit|budget/i.test(JSON.stringify(errs)))) {
|
||||||
if (attempt > 8) throw new Error('monday.com kept rate-limiting the import — try again later');
|
if (attempt > 8) throw fatal('monday.com kept rate-limiting the import — try again later');
|
||||||
const wait = Math.min(60, 5 * (attempt + 1));
|
const wait = Math.min(60, 5 * (attempt + 1));
|
||||||
log(`Rate limited by monday.com, waiting ${wait}s…`);
|
log(`Rate limited by monday.com, waiting ${wait}s…`);
|
||||||
await sleep(wait * 1000);
|
await sleep(wait * 1000);
|
||||||
return gql(token, query, variables, attempt + 1);
|
return gql(token, query, variables, attempt + 1);
|
||||||
}
|
}
|
||||||
if (res.status === 401 || res.status === 403) throw new Error('monday.com rejected the API token');
|
if (res.status === 401 || res.status === 403) throw fatal('monday.com rejected the API token');
|
||||||
if (errs) throw new Error(errs.map((e) => e.message).join('; '));
|
if (errs) throw new Error(errs.map((e) => e.message).join('; '));
|
||||||
return body.data;
|
return body.data;
|
||||||
}
|
}
|
||||||
@@ -60,6 +62,11 @@ function htmlToText(html) {
|
|||||||
|
|
||||||
const parseJSON = (s) => { try { return s ? JSON.parse(s) : null; } catch { return null; } };
|
const parseJSON = (s) => { try { return s ? JSON.parse(s) : null; } catch { return null; } };
|
||||||
|
|
||||||
|
// Only active monday users get an account here, so deactivated staff and outside guests have none.
|
||||||
|
// Their updates keep the author's name with no linked account. Must return null, never undefined:
|
||||||
|
// node:sqlite refuses to bind undefined and the whole import fails.
|
||||||
|
const userIdByEmail = (email) => (email ? get('SELECT id FROM users WHERE email = ?', String(email).toLowerCase())?.id ?? null : null);
|
||||||
|
|
||||||
// monday column type → { type, settings, convert(columnValue) }
|
// monday column type → { type, settings, convert(columnValue) }
|
||||||
function mapColumn(col, ctx) {
|
function mapColumn(col, ctx) {
|
||||||
const st = parseJSON(col.settings_str) || {};
|
const st = parseJSON(col.settings_str) || {};
|
||||||
@@ -226,17 +233,31 @@ async function importAll(token, opts = {}) {
|
|||||||
const wanted = opts.boardIds?.length ? boards.filter((b) => opts.boardIds.includes(String(b.id))) : boards;
|
const wanted = opts.boardIds?.length ? boards.filter((b) => opts.boardIds.includes(String(b.id))) : boards;
|
||||||
log(`${wanted.length} boards to import`);
|
log(`${wanted.length} boards to import`);
|
||||||
|
|
||||||
|
const failed = [];
|
||||||
for (const [bi, b] of wanted.entries()) {
|
for (const [bi, b] of wanted.entries()) {
|
||||||
if (get('SELECT 1 FROM boards WHERE external_id = ?', 'monday:' + b.id)) { log(`Skipping "${b.name}" (already imported)`); continue; }
|
if (get('SELECT 1 FROM boards WHERE external_id = ?', 'monday:' + b.id)) { log(`Skipping "${b.name}" (already imported)`); continue; }
|
||||||
log(`(${bi + 1}/${wanted.length}) ${b.name}`);
|
log(`(${bi + 1}/${wanted.length}) ${b.name}`);
|
||||||
|
const countsBefore = { ...status.counts };
|
||||||
|
try {
|
||||||
await importBoard(token, b, ctx, {
|
await importBoard(token, b, ctx, {
|
||||||
workspaceId: b.workspace_id ? wsMap[b.workspace_id] || mainWs() : mainWs(),
|
workspaceId: b.workspace_id ? wsMap[b.workspace_id] || mainWs() : mainWs(),
|
||||||
folderId: b.board_folder_id ? folderMap[b.board_folder_id] || null : null,
|
folderId: b.board_folder_id ? folderMap[b.board_folder_id] || null : null,
|
||||||
position: bi + 1,
|
position: bi + 1,
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Remove the half-imported board so the next run retries it instead of skipping it as
|
||||||
|
// already imported. Deleting a board cascades to its columns, groups, items and updates.
|
||||||
|
run('DELETE FROM boards WHERE external_id = ?', 'monday:' + b.id);
|
||||||
|
status.counts = countsBefore;
|
||||||
|
if (e.fatal) throw e;
|
||||||
|
failed.push(b.name);
|
||||||
|
log(` "${b.name}" failed and was removed, so the next import retries it: ${e.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
status.counts.boards = (status.counts.boards || 0) + 1;
|
status.counts.boards = (status.counts.boards || 0) + 1;
|
||||||
M.emitTree(null);
|
M.emitTree(null);
|
||||||
}
|
}
|
||||||
|
if (failed.length) status.error = `${failed.length} board${failed.length === 1 ? '' : 's'} failed: ${failed.join(', ')}`;
|
||||||
|
|
||||||
// Tidy: drop imported folders left empty (e.g. only held boards you chose not to import).
|
// Tidy: drop imported folders left empty (e.g. only held boards you chose not to import).
|
||||||
for (let changed = true; changed;) {
|
for (let changed = true; changed;) {
|
||||||
@@ -249,6 +270,7 @@ async function importAll(token, opts = {}) {
|
|||||||
}
|
}
|
||||||
setSetting('mondayImport', { at: now(), counts: status.counts });
|
setSetting('mondayImport', { at: now(), counts: status.counts });
|
||||||
log(`Done — ${status.counts.boards || 0} boards, ${status.counts.items || 0} items, ${status.counts.updates || 0} updates, ${status.counts.files || 0} files`);
|
log(`Done — ${status.counts.boards || 0} boards, ${status.counts.items || 0} items, ${status.counts.updates || 0} updates, ${status.counts.files || 0} files`);
|
||||||
|
if (status.error) log(`${status.error}. Run the import again to retry.`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.error = e.message;
|
status.error = e.message;
|
||||||
log(`FAILED: ${e.message}`);
|
log(`FAILED: ${e.message}`);
|
||||||
@@ -367,13 +389,13 @@ async function importItem(it, { bid, groupMap, colInfo, ctx, position }) {
|
|||||||
// Updates and replies, oldest thread first is irrelevant — they're sorted by date on display.
|
// Updates and replies, oldest thread first is irrelevant — they're sorted by date on display.
|
||||||
for (const u of it.updates || []) {
|
for (const u of it.updates || []) {
|
||||||
const uid = id();
|
const uid = id();
|
||||||
const author = u.creator?.email ? get('SELECT id FROM users WHERE email = ?', u.creator.email.toLowerCase())?.id : null;
|
const author = userIdByEmail(u.creator?.email);
|
||||||
run('INSERT INTO updates(id, item_id, user_id, author_name, body, created_at) VALUES(?,?,?,?,?,?)',
|
run('INSERT INTO updates(id, item_id, user_id, author_name, body, created_at) VALUES(?,?,?,?,?,?)',
|
||||||
uid, iid, author, u.creator?.name || null, htmlToText(u.body), Date.parse(u.created_at) || now());
|
uid, iid, author, u.creator?.name || null, htmlToText(u.body), Date.parse(u.created_at) || now());
|
||||||
status.counts.updates = (status.counts.updates || 0) + 1;
|
status.counts.updates = (status.counts.updates || 0) + 1;
|
||||||
for (const a of u.assets || []) { used.add(String(a.id)); await downloadAsset(a, iid, null, uid, author); }
|
for (const a of u.assets || []) { used.add(String(a.id)); await downloadAsset(a, iid, null, uid, author); }
|
||||||
for (const rep of u.replies || []) {
|
for (const rep of u.replies || []) {
|
||||||
const ra = rep.creator?.email ? get('SELECT id FROM users WHERE email = ?', rep.creator.email.toLowerCase())?.id : null;
|
const ra = userIdByEmail(rep.creator?.email);
|
||||||
run('INSERT INTO updates(id, item_id, parent_id, user_id, author_name, body, created_at) VALUES(?,?,?,?,?,?,?)',
|
run('INSERT INTO updates(id, item_id, parent_id, user_id, author_name, body, created_at) VALUES(?,?,?,?,?,?,?)',
|
||||||
id(), iid, uid, ra, rep.creator?.name || null, htmlToText(rep.body), Date.parse(rep.created_at) || now());
|
id(), iid, uid, ra, rep.creator?.name || null, htmlToText(rep.body), Date.parse(rep.created_at) || now());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user