Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions connectors/airtable/README.md
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
# Airtable Connector for Plot

Sync Airtable task lists into Plot and collaborate on them with two-way comment sync.
Sync Airtable task lists into Plot.

## What it does

- Auto-detects task-like tables across your enabled bases (collaborator + due-date + status fields)
- Syncs records assigned to you as Plot threads with status and due date
- Two-way comment sync: comments on Airtable records appear as notes; notes you write in Plot post back as comments
- Marking a task done in Plot flips the status field / checkbox in Airtable

Comments are not synced. Airtable's standard webhooks don't notify on comment changes (that's an Enterprise-only Change Events feature), so a reliable two-way comment flow isn't possible on standard plans.

## OAuth scopes

- `user.email:read` — identify the connected user
- `schema.bases:read` — list bases, tables, and field definitions for auto-detection
- `data.records:read` / `data.records:write` — read and update records
- `data.recordComments:read` / `data.recordComments:write` — two-way comment sync
- `webhook:manage` — receive real-time updates

## License
Expand Down
119 changes: 44 additions & 75 deletions connectors/airtable/src/airtable-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
* Minimal typed Airtable REST client.
*
* Covers the endpoints the connector needs: whoami, base/table metadata,
* record list/patch, record comments CRUD, and webhook registration.
* record list/patch, and webhook registration.
*
* Throttling: Airtable allows 5 req/sec per base. Each AirtableAPI instance
* paces its own requests to stay just under that ceiling. 429s still get
Expand DownExpand Up@@ -31,6 +31,23 @@ export function isAirtableAuthError(error: unknown): error is AirtableAuthError
return error instanceof AirtableAuthError;
}

export class AirtableNotFoundError extends Error {
constructor(
public readonly method: string,
public readonly path: string,
public readonly body: string
) {
super(`Airtable ${method} ${path} failed: 404 ${body}`);
this.name = "AirtableNotFoundError";
}
}

export function isAirtableNotFoundError(
error: unknown
): error is AirtableNotFoundError {
return error instanceof AirtableNotFoundError;
}

// ---- Response shapes ----

export type AirtableUserInfo = {
Expand DownExpand Up@@ -121,33 +138,6 @@ export type AirtableListRecords = {
offset?: string;
};

export type AirtableCommentAuthor = {
id: string;
email: string;
name?: string;
};

export type AirtableMentioned = Record<
string,
{
type: "user" | "userGroup";
id: string;
displayName?: string;
email?: string;
name?: string;
}
>;

export type AirtableComment = {
id: string;
author: AirtableCommentAuthor;
text: string;
createdTime: string;
lastUpdatedTime: string | null;
parentCommentId?: string;
mentioned?: AirtableMentioned;
};

export type AirtableWebhook = {
id: string;
macSecretBase64: string;
Expand All@@ -174,6 +164,11 @@ export type AirtableWebhookTableChange = {
{ createdTime?: string; cellValuesByFieldId?: Record<string, unknown> }
>;
destroyedRecordIds?: string[];
// Populated when a webhook subscribes to "tableFields". We don't parse
// the contents — their presence alone is the signal to re-detect tables.
changedFieldsById?: Record<string, unknown>;
createdFieldsById?: Record<string, unknown>;
destroyedFieldIds?: string[];
};

export type AirtableWebhookPayload = {
Expand DownExpand Up@@ -277,53 +272,6 @@ export class AirtableAPI {
);
}

async listComments(
baseId: string,
tableId: string,
recordId: string
): Promise<AirtableComment[]> {
const comments: AirtableComment[] = [];
let offset: string | undefined;
do {
const params = new URLSearchParams({ pageSize: "100" });
if (offset) params.set("offset", offset);
const res = await this.req<{ comments: AirtableComment[]; offset?: string }>(
"GET",
`/v0/${baseId}/${tableId}/${recordId}/comments?${params.toString()}`
);
comments.push(...res.comments);
offset = res.offset;
} while (offset);
return comments;
}

async createComment(
baseId: string,
tableId: string,
recordId: string,
body: { text: string; parentCommentId?: string }
): Promise<AirtableComment> {
return this.req<AirtableComment>(
"POST",
`/v0/${baseId}/${tableId}/${recordId}/comments`,
body
);
}

async updateComment(
baseId: string,
tableId: string,
recordId: string,
commentId: string,
body: { text: string }
): Promise<AirtableComment> {
return this.req<AirtableComment>(
"PATCH",
`/v0/${baseId}/${tableId}/${recordId}/comments/${commentId}`,
body
);
}

async createWebhook(
baseId: string,
notificationUrl: string,
Expand DownExpand Up@@ -361,6 +309,23 @@ export class AirtableAPI {
);
}

/**
* Extends a webhook's expiration by 7 days from now. Returns the new
* expiration time. May return `expirationTime: null` for hooks that don't
* expire (rare; defensive). Throws AirtableNotFoundError if the webhook
* has already been deleted or aged out — callers should treat that as a
* cue to recreate the webhook.
*/
async refreshWebhook(
baseId: string,
webhookId: string
): Promise<{ expirationTime: string | null }> {
return this.req<{ expirationTime: string | null }>(
"POST",
`/v0/bases/${baseId}/webhooks/${webhookId}/refresh`
);
}

async listWebhookPayloads(
baseId: string,
webhookId: string,
Expand DownExpand Up@@ -406,6 +371,10 @@ export class AirtableAPI {
const text = await res.text().catch(() => "");
throw new AirtableAuthError(method, path, text);
}
if (res.status === 404) {
const text = await res.text().catch(() => "");
throw new AirtableNotFoundError(method, path, text);
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Airtable ${method} ${path} failed: ${res.status} ${text}`);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions connectors/airtable/README.md
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
# Airtable Connector for Plot

Sync Airtable task lists into Plot and collaborate on them with two-way comment sync.
Sync Airtable task lists into Plot.

## What it does

- Auto-detects task-like tables across your enabled bases (collaborator + due-date + status fields)
- Syncs records assigned to you as Plot threads with status and due date
- Two-way comment sync: comments on Airtable records appear as notes; notes you write in Plot post back as comments
- Marking a task done in Plot flips the status field / checkbox in Airtable

Comments are not synced. Airtable's standard webhooks don't notify on comment changes (that's an Enterprise-only Change Events feature), so a reliable two-way comment flow isn't possible on standard plans.

## OAuth scopes

- `user.email:read` — identify the connected user
- `schema.bases:read` — list bases, tables, and field definitions for auto-detection
- `data.records:read` / `data.records:write` — read and update records
- `data.recordComments:read` / `data.recordComments:write` — two-way comment sync
- `webhook:manage` — receive real-time updates

## License
Expand Down
119 changes: 44 additions & 75 deletions connectors/airtable/src/airtable-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
* Minimal typed Airtable REST client.
*
* Covers the endpoints the connector needs: whoami, base/table metadata,
* record list/patch, record comments CRUD, and webhook registration.
* record list/patch, and webhook registration.
*
* Throttling: Airtable allows 5 req/sec per base. Each AirtableAPI instance
* paces its own requests to stay just under that ceiling. 429s still get
Expand DownExpand Up@@ -31,6 +31,23 @@ export function isAirtableAuthError(error: unknown): error is AirtableAuthError
return error instanceof AirtableAuthError;
}

export class AirtableNotFoundError extends Error {
constructor(
public readonly method: string,
public readonly path: string,
public readonly body: string
) {
super(`Airtable ${method} ${path} failed: 404 ${body}`);
this.name = "AirtableNotFoundError";
}
}

export function isAirtableNotFoundError(
error: unknown
): error is AirtableNotFoundError {
return error instanceof AirtableNotFoundError;
}

// ---- Response shapes ----

export type AirtableUserInfo = {
Expand DownExpand Up@@ -121,33 +138,6 @@ export type AirtableListRecords = {
offset?: string;
};

export type AirtableCommentAuthor = {
id: string;
email: string;
name?: string;
};

export type AirtableMentioned = Record<
string,
{
type: "user" | "userGroup";
id: string;
displayName?: string;
email?: string;
name?: string;
}
>;

export type AirtableComment = {
id: string;
author: AirtableCommentAuthor;
text: string;
createdTime: string;
lastUpdatedTime: string | null;
parentCommentId?: string;
mentioned?: AirtableMentioned;
};

export type AirtableWebhook = {
id: string;
macSecretBase64: string;
Expand All@@ -174,6 +164,11 @@ export type AirtableWebhookTableChange = {
{ createdTime?: string; cellValuesByFieldId?: Record<string, unknown> }
>;
destroyedRecordIds?: string[];
// Populated when a webhook subscribes to "tableFields". We don't parse
// the contents — their presence alone is the signal to re-detect tables.
changedFieldsById?: Record<string, unknown>;
createdFieldsById?: Record<string, unknown>;
destroyedFieldIds?: string[];
};

export type AirtableWebhookPayload = {
Expand DownExpand Up@@ -277,53 +272,6 @@ export class AirtableAPI {
);
}

async listComments(
baseId: string,
tableId: string,
recordId: string
): Promise<AirtableComment[]> {
const comments: AirtableComment[] = [];
let offset: string | undefined;
do {
const params = new URLSearchParams({ pageSize: "100" });
if (offset) params.set("offset", offset);
const res = await this.req<{ comments: AirtableComment[]; offset?: string }>(
"GET",
`/v0/${baseId}/${tableId}/${recordId}/comments?${params.toString()}`
);
comments.push(...res.comments);
offset = res.offset;
} while (offset);
return comments;
}

async createComment(
baseId: string,
tableId: string,
recordId: string,
body: { text: string; parentCommentId?: string }
): Promise<AirtableComment> {
return this.req<AirtableComment>(
"POST",
`/v0/${baseId}/${tableId}/${recordId}/comments`,
body
);
}

async updateComment(
baseId: string,
tableId: string,
recordId: string,
commentId: string,
body: { text: string }
): Promise<AirtableComment> {
return this.req<AirtableComment>(
"PATCH",
`/v0/${baseId}/${tableId}/${recordId}/comments/${commentId}`,
body
);
}

async createWebhook(
baseId: string,
notificationUrl: string,
Expand DownExpand Up@@ -361,6 +309,23 @@ export class AirtableAPI {
);
}

/**
* Extends a webhook's expiration by 7 days from now. Returns the new
* expiration time. May return `expirationTime: null` for hooks that don't
* expire (rare; defensive). Throws AirtableNotFoundError if the webhook
* has already been deleted or aged out — callers should treat that as a
* cue to recreate the webhook.
*/
async refreshWebhook(
baseId: string,
webhookId: string
): Promise<{ expirationTime: string | null }> {
return this.req<{ expirationTime: string | null }>(
"POST",
`/v0/bases/${baseId}/webhooks/${webhookId}/refresh`
);
}

async listWebhookPayloads(
baseId: string,
webhookId: string,
Expand DownExpand Up@@ -406,6 +371,10 @@ export class AirtableAPI {
const text = await res.text().catch(() => "");
throw new AirtableAuthError(method, path, text);
}
if (res.status === 404) {
const text = await res.text().catch(() => "");
throw new AirtableNotFoundError(method, path, text);
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Airtable ${method} ${path} failed: ${res.status} ${text}`);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions connectors/airtable/README.md
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
# Airtable Connector for Plot

Sync Airtable task lists into Plot and collaborate on them with two-way comment sync.
Sync Airtable task lists into Plot.

## What it does

- Auto-detects task-like tables across your enabled bases (collaborator + due-date + status fields)
- Syncs records assigned to you as Plot threads with status and due date
- Two-way comment sync: comments on Airtable records appear as notes; notes you write in Plot post back as comments
- Marking a task done in Plot flips the status field / checkbox in Airtable

Comments are not synced. Airtable's standard webhooks don't notify on comment changes (that's an Enterprise-only Change Events feature), so a reliable two-way comment flow isn't possible on standard plans.

## OAuth scopes

- `user.email:read` — identify the connected user
- `schema.bases:read` — list bases, tables, and field definitions for auto-detection
- `data.records:read` / `data.records:write` — read and update records
- `data.recordComments:read` / `data.recordComments:write` — two-way comment sync
- `webhook:manage` — receive real-time updates

## License
Expand Down
119 changes: 44 additions & 75 deletions connectors/airtable/src/airtable-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
* Minimal typed Airtable REST client.
*
* Covers the endpoints the connector needs: whoami, base/table metadata,
* record list/patch, record comments CRUD, and webhook registration.
* record list/patch, and webhook registration.
*
* Throttling: Airtable allows 5 req/sec per base. Each AirtableAPI instance
* paces its own requests to stay just under that ceiling. 429s still get
Expand DownExpand Up@@ -31,6 +31,23 @@ export function isAirtableAuthError(error: unknown): error is AirtableAuthError
return error instanceof AirtableAuthError;
}

export class AirtableNotFoundError extends Error {
constructor(
public readonly method: string,
public readonly path: string,
public readonly body: string
) {
super(`Airtable ${method} ${path} failed: 404 ${body}`);
this.name = "AirtableNotFoundError";
}
}

export function isAirtableNotFoundError(
error: unknown
): error is AirtableNotFoundError {
return error instanceof AirtableNotFoundError;
}

// ---- Response shapes ----

export type AirtableUserInfo = {
Expand DownExpand Up@@ -121,33 +138,6 @@ export type AirtableListRecords = {
offset?: string;
};

export type AirtableCommentAuthor = {
id: string;
email: string;
name?: string;
};

export type AirtableMentioned = Record<
string,
{
type: "user" | "userGroup";
id: string;
displayName?: string;
email?: string;
name?: string;
}
>;

export type AirtableComment = {
id: string;
author: AirtableCommentAuthor;
text: string;
createdTime: string;
lastUpdatedTime: string | null;
parentCommentId?: string;
mentioned?: AirtableMentioned;
};

export type AirtableWebhook = {
id: string;
macSecretBase64: string;
Expand All@@ -174,6 +164,11 @@ export type AirtableWebhookTableChange = {
{ createdTime?: string; cellValuesByFieldId?: Record<string, unknown> }
>;
destroyedRecordIds?: string[];
// Populated when a webhook subscribes to "tableFields". We don't parse
// the contents — their presence alone is the signal to re-detect tables.
changedFieldsById?: Record<string, unknown>;
createdFieldsById?: Record<string, unknown>;
destroyedFieldIds?: string[];
};

export type AirtableWebhookPayload = {
Expand DownExpand Up@@ -277,53 +272,6 @@ export class AirtableAPI {
);
}

async listComments(
baseId: string,
tableId: string,
recordId: string
): Promise<AirtableComment[]> {
const comments: AirtableComment[] = [];
let offset: string | undefined;
do {
const params = new URLSearchParams({ pageSize: "100" });
if (offset) params.set("offset", offset);
const res = await this.req<{ comments: AirtableComment[]; offset?: string }>(
"GET",
`/v0/${baseId}/${tableId}/${recordId}/comments?${params.toString()}`
);
comments.push(...res.comments);
offset = res.offset;
} while (offset);
return comments;
}

async createComment(
baseId: string,
tableId: string,
recordId: string,
body: { text: string; parentCommentId?: string }
): Promise<AirtableComment> {
return this.req<AirtableComment>(
"POST",
`/v0/${baseId}/${tableId}/${recordId}/comments`,
body
);
}

async updateComment(
baseId: string,
tableId: string,
recordId: string,
commentId: string,
body: { text: string }
): Promise<AirtableComment> {
return this.req<AirtableComment>(
"PATCH",
`/v0/${baseId}/${tableId}/${recordId}/comments/${commentId}`,
body
);
}

async createWebhook(
baseId: string,
notificationUrl: string,
Expand DownExpand Up@@ -361,6 +309,23 @@ export class AirtableAPI {
);
}

/**
* Extends a webhook's expiration by 7 days from now. Returns the new
* expiration time. May return `expirationTime: null` for hooks that don't
* expire (rare; defensive). Throws AirtableNotFoundError if the webhook
* has already been deleted or aged out — callers should treat that as a
* cue to recreate the webhook.
*/
async refreshWebhook(
baseId: string,
webhookId: string
): Promise<{ expirationTime: string | null }> {
return this.req<{ expirationTime: string | null }>(
"POST",
`/v0/bases/${baseId}/webhooks/${webhookId}/refresh`
);
}

async listWebhookPayloads(
baseId: string,
webhookId: string,
Expand DownExpand Up@@ -406,6 +371,10 @@ export class AirtableAPI {
const text = await res.text().catch(() => "");
throw new AirtableAuthError(method, path, text);
}
if (res.status === 404) {
const text = await res.text().catch(() => "");
throw new AirtableNotFoundError(method, path, text);
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Airtable ${method} ${path} failed: ${res.status} ${text}`);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions connectors/airtable/README.md
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
# Airtable Connector for Plot

Sync Airtable task lists into Plot and collaborate on them with two-way comment sync.
Sync Airtable task lists into Plot.

## What it does

- Auto-detects task-like tables across your enabled bases (collaborator + due-date + status fields)
- Syncs records assigned to you as Plot threads with status and due date
- Two-way comment sync: comments on Airtable records appear as notes; notes you write in Plot post back as comments
- Marking a task done in Plot flips the status field / checkbox in Airtable

Comments are not synced. Airtable's standard webhooks don't notify on comment changes (that's an Enterprise-only Change Events feature), so a reliable two-way comment flow isn't possible on standard plans.

## OAuth scopes

- `user.email:read` — identify the connected user
- `schema.bases:read` — list bases, tables, and field definitions for auto-detection
- `data.records:read` / `data.records:write` — read and update records
- `data.recordComments:read` / `data.recordComments:write` — two-way comment sync
- `webhook:manage` — receive real-time updates

## License
Expand Down
119 changes: 44 additions & 75 deletions connectors/airtable/src/airtable-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
* Minimal typed Airtable REST client.
*
* Covers the endpoints the connector needs: whoami, base/table metadata,
* record list/patch, record comments CRUD, and webhook registration.
* record list/patch, and webhook registration.
*
* Throttling: Airtable allows 5 req/sec per base. Each AirtableAPI instance
* paces its own requests to stay just under that ceiling. 429s still get
Expand DownExpand Up@@ -31,6 +31,23 @@ export function isAirtableAuthError(error: unknown): error is AirtableAuthError
return error instanceof AirtableAuthError;
}

export class AirtableNotFoundError extends Error {
constructor(
public readonly method: string,
public readonly path: string,
public readonly body: string
) {
super(`Airtable ${method} ${path} failed: 404 ${body}`);
this.name = "AirtableNotFoundError";
}
}

export function isAirtableNotFoundError(
error: unknown
): error is AirtableNotFoundError {
return error instanceof AirtableNotFoundError;
}

// ---- Response shapes ----

export type AirtableUserInfo = {
Expand DownExpand Up@@ -121,33 +138,6 @@ export type AirtableListRecords = {
offset?: string;
};

export type AirtableCommentAuthor = {
id: string;
email: string;
name?: string;
};

export type AirtableMentioned = Record<
string,
{
type: "user" | "userGroup";
id: string;
displayName?: string;
email?: string;
name?: string;
}
>;

export type AirtableComment = {
id: string;
author: AirtableCommentAuthor;
text: string;
createdTime: string;
lastUpdatedTime: string | null;
parentCommentId?: string;
mentioned?: AirtableMentioned;
};

export type AirtableWebhook = {
id: string;
macSecretBase64: string;
Expand All@@ -174,6 +164,11 @@ export type AirtableWebhookTableChange = {
{ createdTime?: string; cellValuesByFieldId?: Record<string, unknown> }
>;
destroyedRecordIds?: string[];
// Populated when a webhook subscribes to "tableFields". We don't parse
// the contents — their presence alone is the signal to re-detect tables.
changedFieldsById?: Record<string, unknown>;
createdFieldsById?: Record<string, unknown>;
destroyedFieldIds?: string[];
};

export type AirtableWebhookPayload = {
Expand DownExpand Up@@ -277,53 +272,6 @@ export class AirtableAPI {
);
}

async listComments(
baseId: string,
tableId: string,
recordId: string
): Promise<AirtableComment[]> {
const comments: AirtableComment[] = [];
let offset: string | undefined;
do {
const params = new URLSearchParams({ pageSize: "100" });
if (offset) params.set("offset", offset);
const res = await this.req<{ comments: AirtableComment[]; offset?: string }>(
"GET",
`/v0/${baseId}/${tableId}/${recordId}/comments?${params.toString()}`
);
comments.push(...res.comments);
offset = res.offset;
} while (offset);
return comments;
}

async createComment(
baseId: string,
tableId: string,
recordId: string,
body: { text: string; parentCommentId?: string }
): Promise<AirtableComment> {
return this.req<AirtableComment>(
"POST",
`/v0/${baseId}/${tableId}/${recordId}/comments`,
body
);
}

async updateComment(
baseId: string,
tableId: string,
recordId: string,
commentId: string,
body: { text: string }
): Promise<AirtableComment> {
return this.req<AirtableComment>(
"PATCH",
`/v0/${baseId}/${tableId}/${recordId}/comments/${commentId}`,
body
);
}

async createWebhook(
baseId: string,
notificationUrl: string,
Expand DownExpand Up@@ -361,6 +309,23 @@ export class AirtableAPI {
);
}

/**
* Extends a webhook's expiration by 7 days from now. Returns the new
* expiration time. May return `expirationTime: null` for hooks that don't
* expire (rare; defensive). Throws AirtableNotFoundError if the webhook
* has already been deleted or aged out — callers should treat that as a
* cue to recreate the webhook.
*/
async refreshWebhook(
baseId: string,
webhookId: string
): Promise<{ expirationTime: string | null }> {
return this.req<{ expirationTime: string | null }>(
"POST",
`/v0/bases/${baseId}/webhooks/${webhookId}/refresh`
);
}

async listWebhookPayloads(
baseId: string,
webhookId: string,
Expand DownExpand Up@@ -406,6 +371,10 @@ export class AirtableAPI {
const text = await res.text().catch(() => "");
throw new AirtableAuthError(method, path, text);
}
if (res.status === 404) {
const text = await res.text().catch(() => "");
throw new AirtableNotFoundError(method, path, text);
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Airtable ${method} ${path} failed: ${res.status} ${text}`);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions connectors/airtable/README.md
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
# Airtable Connector for Plot

Sync Airtable task lists into Plot and collaborate on them with two-way comment sync.
Sync Airtable task lists into Plot.

## What it does

- Auto-detects task-like tables across your enabled bases (collaborator + due-date + status fields)
- Syncs records assigned to you as Plot threads with status and due date
- Two-way comment sync: comments on Airtable records appear as notes; notes you write in Plot post back as comments
- Marking a task done in Plot flips the status field / checkbox in Airtable

Comments are not synced. Airtable's standard webhooks don't notify on comment changes (that's an Enterprise-only Change Events feature), so a reliable two-way comment flow isn't possible on standard plans.

## OAuth scopes

- `user.email:read` — identify the connected user
- `schema.bases:read` — list bases, tables, and field definitions for auto-detection
- `data.records:read` / `data.records:write` — read and update records
- `data.recordComments:read` / `data.recordComments:write` — two-way comment sync
- `webhook:manage` — receive real-time updates

## License
Expand Down
119 changes: 44 additions & 75 deletions connectors/airtable/src/airtable-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
* Minimal typed Airtable REST client.
*
* Covers the endpoints the connector needs: whoami, base/table metadata,
* record list/patch, record comments CRUD, and webhook registration.
* record list/patch, and webhook registration.
*
* Throttling: Airtable allows 5 req/sec per base. Each AirtableAPI instance
* paces its own requests to stay just under that ceiling. 429s still get
Expand DownExpand Up@@ -31,6 +31,23 @@ export function isAirtableAuthError(error: unknown): error is AirtableAuthError
return error instanceof AirtableAuthError;
}

export class AirtableNotFoundError extends Error {
constructor(
public readonly method: string,
public readonly path: string,
public readonly body: string
) {
super(`Airtable ${method} ${path} failed: 404 ${body}`);
this.name = "AirtableNotFoundError";
}
}

export function isAirtableNotFoundError(
error: unknown
): error is AirtableNotFoundError {
return error instanceof AirtableNotFoundError;
}

// ---- Response shapes ----

export type AirtableUserInfo = {
Expand DownExpand Up@@ -121,33 +138,6 @@ export type AirtableListRecords = {
offset?: string;
};

export type AirtableCommentAuthor = {
id: string;
email: string;
name?: string;
};

export type AirtableMentioned = Record<
string,
{
type: "user" | "userGroup";
id: string;
displayName?: string;
email?: string;
name?: string;
}
>;

export type AirtableComment = {
id: string;
author: AirtableCommentAuthor;
text: string;
createdTime: string;
lastUpdatedTime: string | null;
parentCommentId?: string;
mentioned?: AirtableMentioned;
};

export type AirtableWebhook = {
id: string;
macSecretBase64: string;
Expand All@@ -174,6 +164,11 @@ export type AirtableWebhookTableChange = {
{ createdTime?: string; cellValuesByFieldId?: Record<string, unknown> }
>;
destroyedRecordIds?: string[];
// Populated when a webhook subscribes to "tableFields". We don't parse
// the contents — their presence alone is the signal to re-detect tables.
changedFieldsById?: Record<string, unknown>;
createdFieldsById?: Record<string, unknown>;
destroyedFieldIds?: string[];
};

export type AirtableWebhookPayload = {
Expand DownExpand Up@@ -277,53 +272,6 @@ export class AirtableAPI {
);
}

async listComments(
baseId: string,
tableId: string,
recordId: string
): Promise<AirtableComment[]> {
const comments: AirtableComment[] = [];
let offset: string | undefined;
do {
const params = new URLSearchParams({ pageSize: "100" });
if (offset) params.set("offset", offset);
const res = await this.req<{ comments: AirtableComment[]; offset?: string }>(
"GET",
`/v0/${baseId}/${tableId}/${recordId}/comments?${params.toString()}`
);
comments.push(...res.comments);
offset = res.offset;
} while (offset);
return comments;
}

async createComment(
baseId: string,
tableId: string,
recordId: string,
body: { text: string; parentCommentId?: string }
): Promise<AirtableComment> {
return this.req<AirtableComment>(
"POST",
`/v0/${baseId}/${tableId}/${recordId}/comments`,
body
);
}

async updateComment(
baseId: string,
tableId: string,
recordId: string,
commentId: string,
body: { text: string }
): Promise<AirtableComment> {
return this.req<AirtableComment>(
"PATCH",
`/v0/${baseId}/${tableId}/${recordId}/comments/${commentId}`,
body
);
}

async createWebhook(
baseId: string,
notificationUrl: string,
Expand DownExpand Up@@ -361,6 +309,23 @@ export class AirtableAPI {
);
}

/**
* Extends a webhook's expiration by 7 days from now. Returns the new
* expiration time. May return `expirationTime: null` for hooks that don't
* expire (rare; defensive). Throws AirtableNotFoundError if the webhook
* has already been deleted or aged out — callers should treat that as a
* cue to recreate the webhook.
*/
async refreshWebhook(
baseId: string,
webhookId: string
): Promise<{ expirationTime: string | null }> {
return this.req<{ expirationTime: string | null }>(
"POST",
`/v0/bases/${baseId}/webhooks/${webhookId}/refresh`
);
}

async listWebhookPayloads(
baseId: string,
webhookId: string,
Expand DownExpand Up@@ -406,6 +371,10 @@ export class AirtableAPI {
const text = await res.text().catch(() => "");
throw new AirtableAuthError(method, path, text);
}
if (res.status === 404) {
const text = await res.text().catch(() => "");
throw new AirtableNotFoundError(method, path, text);
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Airtable ${method} ${path} failed: ${res.status} ${text}`);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions connectors/airtable/README.md
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
# Airtable Connector for Plot

Sync Airtable task lists into Plot and collaborate on them with two-way comment sync.
Sync Airtable task lists into Plot.

## What it does

- Auto-detects task-like tables across your enabled bases (collaborator + due-date + status fields)
- Syncs records assigned to you as Plot threads with status and due date
- Two-way comment sync: comments on Airtable records appear as notes; notes you write in Plot post back as comments
- Marking a task done in Plot flips the status field / checkbox in Airtable

Comments are not synced. Airtable's standard webhooks don't notify on comment changes (that's an Enterprise-only Change Events feature), so a reliable two-way comment flow isn't possible on standard plans.

## OAuth scopes

- `user.email:read` — identify the connected user
- `schema.bases:read` — list bases, tables, and field definitions for auto-detection
- `data.records:read` / `data.records:write` — read and update records
- `data.recordComments:read` / `data.recordComments:write` — two-way comment sync
- `webhook:manage` — receive real-time updates

## License
Expand Down
119 changes: 44 additions & 75 deletions connectors/airtable/src/airtable-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
* Minimal typed Airtable REST client.
*
* Covers the endpoints the connector needs: whoami, base/table metadata,
* record list/patch, record comments CRUD, and webhook registration.
* record list/patch, and webhook registration.
*
* Throttling: Airtable allows 5 req/sec per base. Each AirtableAPI instance
* paces its own requests to stay just under that ceiling. 429s still get
Expand DownExpand Up@@ -31,6 +31,23 @@ export function isAirtableAuthError(error: unknown): error is AirtableAuthError
return error instanceof AirtableAuthError;
}

export class AirtableNotFoundError extends Error {
constructor(
public readonly method: string,
public readonly path: string,
public readonly body: string
) {
super(`Airtable ${method} ${path} failed: 404 ${body}`);
this.name = "AirtableNotFoundError";
}
}

export function isAirtableNotFoundError(
error: unknown
): error is AirtableNotFoundError {
return error instanceof AirtableNotFoundError;
}

// ---- Response shapes ----

export type AirtableUserInfo = {
Expand DownExpand Up@@ -121,33 +138,6 @@ export type AirtableListRecords = {
offset?: string;
};

export type AirtableCommentAuthor = {
id: string;
email: string;
name?: string;
};

export type AirtableMentioned = Record<
string,
{
type: "user" | "userGroup";
id: string;
displayName?: string;
email?: string;
name?: string;
}
>;

export type AirtableComment = {
id: string;
author: AirtableCommentAuthor;
text: string;
createdTime: string;
lastUpdatedTime: string | null;
parentCommentId?: string;
mentioned?: AirtableMentioned;
};

export type AirtableWebhook = {
id: string;
macSecretBase64: string;
Expand All@@ -174,6 +164,11 @@ export type AirtableWebhookTableChange = {
{ createdTime?: string; cellValuesByFieldId?: Record<string, unknown> }
>;
destroyedRecordIds?: string[];
// Populated when a webhook subscribes to "tableFields". We don't parse
// the contents — their presence alone is the signal to re-detect tables.
changedFieldsById?: Record<string, unknown>;
createdFieldsById?: Record<string, unknown>;
destroyedFieldIds?: string[];
};

export type AirtableWebhookPayload = {
Expand DownExpand Up@@ -277,53 +272,6 @@ export class AirtableAPI {
);
}

async listComments(
baseId: string,
tableId: string,
recordId: string
): Promise<AirtableComment[]> {
const comments: AirtableComment[] = [];
let offset: string | undefined;
do {
const params = new URLSearchParams({ pageSize: "100" });
if (offset) params.set("offset", offset);
const res = await this.req<{ comments: AirtableComment[]; offset?: string }>(
"GET",
`/v0/${baseId}/${tableId}/${recordId}/comments?${params.toString()}`
);
comments.push(...res.comments);
offset = res.offset;
} while (offset);
return comments;
}

async createComment(
baseId: string,
tableId: string,
recordId: string,
body: { text: string; parentCommentId?: string }
): Promise<AirtableComment> {
return this.req<AirtableComment>(
"POST",
`/v0/${baseId}/${tableId}/${recordId}/comments`,
body
);
}

async updateComment(
baseId: string,
tableId: string,
recordId: string,
commentId: string,
body: { text: string }
): Promise<AirtableComment> {
return this.req<AirtableComment>(
"PATCH",
`/v0/${baseId}/${tableId}/${recordId}/comments/${commentId}`,
body
);
}

async createWebhook(
baseId: string,
notificationUrl: string,
Expand DownExpand Up@@ -361,6 +309,23 @@ export class AirtableAPI {
);
}

/**
* Extends a webhook's expiration by 7 days from now. Returns the new
* expiration time. May return `expirationTime: null` for hooks that don't
* expire (rare; defensive). Throws AirtableNotFoundError if the webhook
* has already been deleted or aged out — callers should treat that as a
* cue to recreate the webhook.
*/
async refreshWebhook(
baseId: string,
webhookId: string
): Promise<{ expirationTime: string | null }> {
return this.req<{ expirationTime: string | null }>(
"POST",
`/v0/bases/${baseId}/webhooks/${webhookId}/refresh`
);
}

async listWebhookPayloads(
baseId: string,
webhookId: string,
Expand DownExpand Up@@ -406,6 +371,10 @@ export class AirtableAPI {
const text = await res.text().catch(() => "");
throw new AirtableAuthError(method, path, text);
}
if (res.status === 404) {
const text = await res.text().catch(() => "");
throw new AirtableNotFoundError(method, path, text);
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Airtable ${method} ${path} failed: ${res.status} ${text}`);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions connectors/airtable/README.md
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
# Airtable Connector for Plot

Sync Airtable task lists into Plot and collaborate on them with two-way comment sync.
Sync Airtable task lists into Plot.

## What it does

- Auto-detects task-like tables across your enabled bases (collaborator + due-date + status fields)
- Syncs records assigned to you as Plot threads with status and due date
- Two-way comment sync: comments on Airtable records appear as notes; notes you write in Plot post back as comments
- Marking a task done in Plot flips the status field / checkbox in Airtable

Comments are not synced. Airtable's standard webhooks don't notify on comment changes (that's an Enterprise-only Change Events feature), so a reliable two-way comment flow isn't possible on standard plans.

## OAuth scopes

- `user.email:read` — identify the connected user
- `schema.bases:read` — list bases, tables, and field definitions for auto-detection
- `data.records:read` / `data.records:write` — read and update records
- `data.recordComments:read` / `data.recordComments:write` — two-way comment sync
- `webhook:manage` — receive real-time updates

## License
Expand Down
119 changes: 44 additions & 75 deletions connectors/airtable/src/airtable-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
* Minimal typed Airtable REST client.
*
* Covers the endpoints the connector needs: whoami, base/table metadata,
* record list/patch, record comments CRUD, and webhook registration.
* record list/patch, and webhook registration.
*
* Throttling: Airtable allows 5 req/sec per base. Each AirtableAPI instance
* paces its own requests to stay just under that ceiling. 429s still get
Expand DownExpand Up@@ -31,6 +31,23 @@ export function isAirtableAuthError(error: unknown): error is AirtableAuthError
return error instanceof AirtableAuthError;
}

export class AirtableNotFoundError extends Error {
constructor(
public readonly method: string,
public readonly path: string,
public readonly body: string
) {
super(`Airtable ${method} ${path} failed: 404 ${body}`);
this.name = "AirtableNotFoundError";
}
}

export function isAirtableNotFoundError(
error: unknown
): error is AirtableNotFoundError {
return error instanceof AirtableNotFoundError;
}

// ---- Response shapes ----

export type AirtableUserInfo = {
Expand DownExpand Up@@ -121,33 +138,6 @@ export type AirtableListRecords = {
offset?: string;
};

export type AirtableCommentAuthor = {
id: string;
email: string;
name?: string;
};

export type AirtableMentioned = Record<
string,
{
type: "user" | "userGroup";
id: string;
displayName?: string;
email?: string;
name?: string;
}
>;

export type AirtableComment = {
id: string;
author: AirtableCommentAuthor;
text: string;
createdTime: string;
lastUpdatedTime: string | null;
parentCommentId?: string;
mentioned?: AirtableMentioned;
};

export type AirtableWebhook = {
id: string;
macSecretBase64: string;
Expand All@@ -174,6 +164,11 @@ export type AirtableWebhookTableChange = {
{ createdTime?: string; cellValuesByFieldId?: Record<string, unknown> }
>;
destroyedRecordIds?: string[];
// Populated when a webhook subscribes to "tableFields". We don't parse
// the contents — their presence alone is the signal to re-detect tables.
changedFieldsById?: Record<string, unknown>;
createdFieldsById?: Record<string, unknown>;
destroyedFieldIds?: string[];
};

export type AirtableWebhookPayload = {
Expand DownExpand Up@@ -277,53 +272,6 @@ export class AirtableAPI {
);
}

async listComments(
baseId: string,
tableId: string,
recordId: string
): Promise<AirtableComment[]> {
const comments: AirtableComment[] = [];
let offset: string | undefined;
do {
const params = new URLSearchParams({ pageSize: "100" });
if (offset) params.set("offset", offset);
const res = await this.req<{ comments: AirtableComment[]; offset?: string }>(
"GET",
`/v0/${baseId}/${tableId}/${recordId}/comments?${params.toString()}`
);
comments.push(...res.comments);
offset = res.offset;
} while (offset);
return comments;
}

async createComment(
baseId: string,
tableId: string,
recordId: string,
body: { text: string; parentCommentId?: string }
): Promise<AirtableComment> {
return this.req<AirtableComment>(
"POST",
`/v0/${baseId}/${tableId}/${recordId}/comments`,
body
);
}

async updateComment(
baseId: string,
tableId: string,
recordId: string,
commentId: string,
body: { text: string }
): Promise<AirtableComment> {
return this.req<AirtableComment>(
"PATCH",
`/v0/${baseId}/${tableId}/${recordId}/comments/${commentId}`,
body
);
}

async createWebhook(
baseId: string,
notificationUrl: string,
Expand DownExpand Up@@ -361,6 +309,23 @@ export class AirtableAPI {
);
}

/**
* Extends a webhook's expiration by 7 days from now. Returns the new
* expiration time. May return `expirationTime: null` for hooks that don't
* expire (rare; defensive). Throws AirtableNotFoundError if the webhook
* has already been deleted or aged out — callers should treat that as a
* cue to recreate the webhook.
*/
async refreshWebhook(
baseId: string,
webhookId: string
): Promise<{ expirationTime: string | null }> {
return this.req<{ expirationTime: string | null }>(
"POST",
`/v0/bases/${baseId}/webhooks/${webhookId}/refresh`
);
}

async listWebhookPayloads(
baseId: string,
webhookId: string,
Expand DownExpand Up@@ -406,6 +371,10 @@ export class AirtableAPI {
const text = await res.text().catch(() => "");
throw new AirtableAuthError(method, path, text);
}
if (res.status === 404) {
const text = await res.text().catch(() => "");
throw new AirtableNotFoundError(method, path, text);
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Airtable ${method} ${path} failed: ${res.status} ${text}`);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions connectors/airtable/README.md
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
# Airtable Connector for Plot

Sync Airtable task lists into Plot and collaborate on them with two-way comment sync.
Sync Airtable task lists into Plot.

## What it does

- Auto-detects task-like tables across your enabled bases (collaborator + due-date + status fields)
- Syncs records assigned to you as Plot threads with status and due date
- Two-way comment sync: comments on Airtable records appear as notes; notes you write in Plot post back as comments
- Marking a task done in Plot flips the status field / checkbox in Airtable

Comments are not synced. Airtable's standard webhooks don't notify on comment changes (that's an Enterprise-only Change Events feature), so a reliable two-way comment flow isn't possible on standard plans.

## OAuth scopes

- `user.email:read` — identify the connected user
- `schema.bases:read` — list bases, tables, and field definitions for auto-detection
- `data.records:read` / `data.records:write` — read and update records
- `data.recordComments:read` / `data.recordComments:write` — two-way comment sync
- `webhook:manage` — receive real-time updates

## License
Expand Down
119 changes: 44 additions & 75 deletions connectors/airtable/src/airtable-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
* Minimal typed Airtable REST client.
*
* Covers the endpoints the connector needs: whoami, base/table metadata,
* record list/patch, record comments CRUD, and webhook registration.
* record list/patch, and webhook registration.
*
* Throttling: Airtable allows 5 req/sec per base. Each AirtableAPI instance
* paces its own requests to stay just under that ceiling. 429s still get
Expand DownExpand Up@@ -31,6 +31,23 @@ export function isAirtableAuthError(error: unknown): error is AirtableAuthError
return error instanceof AirtableAuthError;
}

export class AirtableNotFoundError extends Error {
constructor(
public readonly method: string,
public readonly path: string,
public readonly body: string
) {
super(`Airtable ${method} ${path} failed: 404 ${body}`);
this.name = "AirtableNotFoundError";
}
}

export function isAirtableNotFoundError(
error: unknown
): error is AirtableNotFoundError {
return error instanceof AirtableNotFoundError;
}

// ---- Response shapes ----

export type AirtableUserInfo = {
Expand DownExpand Up@@ -121,33 +138,6 @@ export type AirtableListRecords = {
offset?: string;
};

export type AirtableCommentAuthor = {
id: string;
email: string;
name?: string;
};

export type AirtableMentioned = Record<
string,
{
type: "user" | "userGroup";
id: string;
displayName?: string;
email?: string;
name?: string;
}
>;

export type AirtableComment = {
id: string;
author: AirtableCommentAuthor;
text: string;
createdTime: string;
lastUpdatedTime: string | null;
parentCommentId?: string;
mentioned?: AirtableMentioned;
};

export type AirtableWebhook = {
id: string;
macSecretBase64: string;
Expand All@@ -174,6 +164,11 @@ export type AirtableWebhookTableChange = {
{ createdTime?: string; cellValuesByFieldId?: Record<string, unknown> }
>;
destroyedRecordIds?: string[];
// Populated when a webhook subscribes to "tableFields". We don't parse
// the contents — their presence alone is the signal to re-detect tables.
changedFieldsById?: Record<string, unknown>;
createdFieldsById?: Record<string, unknown>;
destroyedFieldIds?: string[];
};

export type AirtableWebhookPayload = {
Expand DownExpand Up@@ -277,53 +272,6 @@ export class AirtableAPI {
);
}

async listComments(
baseId: string,
tableId: string,
recordId: string
): Promise<AirtableComment[]> {
const comments: AirtableComment[] = [];
let offset: string | undefined;
do {
const params = new URLSearchParams({ pageSize: "100" });
if (offset) params.set("offset", offset);
const res = await this.req<{ comments: AirtableComment[]; offset?: string }>(
"GET",
`/v0/${baseId}/${tableId}/${recordId}/comments?${params.toString()}`
);
comments.push(...res.comments);
offset = res.offset;
} while (offset);
return comments;
}

async createComment(
baseId: string,
tableId: string,
recordId: string,
body: { text: string; parentCommentId?: string }
): Promise<AirtableComment> {
return this.req<AirtableComment>(
"POST",
`/v0/${baseId}/${tableId}/${recordId}/comments`,
body
);
}

async updateComment(
baseId: string,
tableId: string,
recordId: string,
commentId: string,
body: { text: string }
): Promise<AirtableComment> {
return this.req<AirtableComment>(
"PATCH",
`/v0/${baseId}/${tableId}/${recordId}/comments/${commentId}`,
body
);
}

async createWebhook(
baseId: string,
notificationUrl: string,
Expand DownExpand Up@@ -361,6 +309,23 @@ export class AirtableAPI {
);
}

/**
* Extends a webhook's expiration by 7 days from now. Returns the new
* expiration time. May return `expirationTime: null` for hooks that don't
* expire (rare; defensive). Throws AirtableNotFoundError if the webhook
* has already been deleted or aged out — callers should treat that as a
* cue to recreate the webhook.
*/
async refreshWebhook(
baseId: string,
webhookId: string
): Promise<{ expirationTime: string | null }> {
return this.req<{ expirationTime: string | null }>(
"POST",
`/v0/bases/${baseId}/webhooks/${webhookId}/refresh`
);
}

async listWebhookPayloads(
baseId: string,
webhookId: string,
Expand DownExpand Up@@ -406,6 +371,10 @@ export class AirtableAPI {
const text = await res.text().catch(() => "");
throw new AirtableAuthError(method, path, text);
}
if (res.status === 404) {
const text = await res.text().catch(() => "");
throw new AirtableNotFoundError(method, path, text);
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Airtable ${method} ${path} failed: ${res.status} ${text}`);
Expand Down
Loading
Loading