mirror of
https://github.com/heymoosh/ticktick-quick-add-obsidian.git
synced 2026-07-22 05:43:55 +00:00
TickTick's OAuth server requires byte-exact match between the redirect_uri the plugin sends and the value registered in the developer portal. Users who register the URI with a trailing slash (common on iOS paste, or by hand) get rejected with: error="invalid_grant" error_description="Invalid redirect: ...vercel.app does not match one of the registered values: [...vercel.app/]" (Reported in issue #2.) Normalize the redirect URI by stripping trailing slashes and whitespace at all four use/save sites: - main.ts: startAuthFlow() and exchangeAuthCodeForToken() clean the URI before sending — protects existing users with a slash already saved - settings.ts: both Redirect URI inputs strip on save so the persisted value matches what's displayed - settings.ts: both descriptions now warn against trailing slashes - README: new Troubleshooting entry for the invalid_grant error https://claude.ai/code/session_01V2fsiatACzjUCNwSEhwER9
419 lines
17 KiB
TypeScript
419 lines
17 KiB
TypeScript
import { arrayBufferToBase64 } from 'obsidian';
|
|
import { App, Editor, MarkdownView, Menu, Modal, Notice, Plugin, TFile, requestUrl } from 'obsidian';
|
|
import { TickTickSettingTab, TickTickSettings, DEFAULT_SETTINGS } from './settings';
|
|
|
|
// Helper function to generate a random alphanumeric string (for block anchors and state)
|
|
function generateRandomString(length: number): string {
|
|
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
let text = '';
|
|
for (let i = 0; i < length; i++) {
|
|
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
|
}
|
|
return text;
|
|
}
|
|
|
|
// Helper function to retrieve the current line where the cursor is located
|
|
function getCurrentLine(editor: Editor, line: number): { text: string, line: number } {
|
|
return { text: editor.getLine(line), line };
|
|
}
|
|
|
|
// Helper function to retrieve the paragraph (consecutive lines) where the cursor is located
|
|
function getParagraph(editor: Editor, currentLine: number): { text: string, start: number, end: number } {
|
|
let start = currentLine;
|
|
let end = currentLine;
|
|
const totalLines = editor.lineCount();
|
|
while (start > 0) {
|
|
if (editor.getLine(start - 1).trim() === "") break;
|
|
start--;
|
|
}
|
|
while (end < totalLines - 1) {
|
|
if (editor.getLine(end + 1).trim() === "") break;
|
|
end++;
|
|
}
|
|
let paragraphText = "";
|
|
for (let i = start; i <= end; i++) {
|
|
paragraphText += editor.getLine(i) + "\n";
|
|
}
|
|
return { text: paragraphText.trim(), start, end };
|
|
}
|
|
|
|
// Strip Markdown formatting so the TickTick task title is plain text.
|
|
// Removes leading list/checkbox/heading/quote markers and inline emphasis,
|
|
// keeping the inner text. Leaves single underscores alone to preserve
|
|
// snake_case identifiers.
|
|
function stripMarkdown(text: string): string {
|
|
return text
|
|
// Images:  -> alt
|
|
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
// Markdown links: [text](url) -> text
|
|
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
// Wiki links: [[page|alias]] -> alias, [[page]] -> page
|
|
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '$2')
|
|
.replace(/\[\[([^\]]+)\]\]/g, '$1')
|
|
// Leading blockquote / heading / list / checkbox / ordered-list markers
|
|
.replace(/^\s*(?:>\s*|#{1,6}\s+|[-*+]\s+\[[ xX]\]\s+|[-*+]\s+|\d+\.\s+)+/, '')
|
|
// Bold/italic via underscores: only strip matched pairs, so
|
|
// snake_case identifiers are left intact.
|
|
.replace(/__([^_]+)__/g, '$1')
|
|
// Asterisk emphasis, highlight, strikethrough, inline code:
|
|
// remove the markers whether or not they're balanced (a dangling
|
|
// "**" at the start of a line should still be stripped).
|
|
.replace(/\*\*?|~~|==|`/g, '')
|
|
// Collapse remaining whitespace/newlines
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
// Helper function to generate PKCE codes: codeVerifier and corresponding codeChallenge
|
|
async function generatePKCECodes(): Promise<{ codeVerifier: string; codeChallenge: string }> {
|
|
const codeVerifier = generateRandomString(64); // between 43 and 128 characters
|
|
// Compute SHA-256 hash of the verifier
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(codeVerifier);
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
const codeChallenge = arrayBufferToBase64(hashBuffer)
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=+$/, '');
|
|
return { codeVerifier, codeChallenge };
|
|
}
|
|
|
|
// Modal that presents the OAuth URL as a tappable link.
|
|
// iOS blocks programmatic window.open after async work loses the user gesture,
|
|
// so we let the user tap the link directly — that tap is itself the gesture.
|
|
class AuthLinkModal extends Modal {
|
|
constructor(app: App, private authUrl: string) {
|
|
super(app);
|
|
}
|
|
|
|
onOpen() {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
contentEl.createEl('h2', { text: 'Authorize TickTick' });
|
|
contentEl.createEl('p', {
|
|
text: 'Tap the link below to open the TickTick login page. After you approve access, you will be returned to Obsidian automatically.'
|
|
});
|
|
const link = contentEl.createEl('a', {
|
|
text: 'Open TickTick login',
|
|
href: this.authUrl
|
|
});
|
|
link.setAttr('target', '_blank');
|
|
link.setAttr('rel', 'noopener');
|
|
link.style.display = 'inline-block';
|
|
link.style.marginTop = '0.5em';
|
|
link.style.fontWeight = 'bold';
|
|
}
|
|
|
|
onClose() {
|
|
this.contentEl.empty();
|
|
}
|
|
}
|
|
|
|
// Structured logging helper
|
|
function log(plugin: TickTickPlugin, action: string, metadata?: Record<string, unknown>) {
|
|
console.log(JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
plugin: 'ticktick-integration',
|
|
action,
|
|
...metadata,
|
|
// Redact sensitive info
|
|
clientId: plugin.settings.clientId ? '***' : undefined,
|
|
accessToken: plugin.settings.accessToken ? '***' : undefined
|
|
}));
|
|
}
|
|
|
|
export default class TickTickPlugin extends Plugin {
|
|
settings: TickTickSettings;
|
|
|
|
async onload() {
|
|
console.log('Loading TickTick Plugin');
|
|
await this.loadSettings();
|
|
this.addSettingTab(new TickTickSettingTab(this.app, this));
|
|
|
|
this.registerObsidianProtocolHandler('ticktick-callback', async (params) => {
|
|
if (!params.code) {
|
|
new Notice('TickTick auth failed: no authorization code returned.');
|
|
return;
|
|
}
|
|
if (!this.settings.tempState || params.state !== this.settings.tempState) {
|
|
new Notice('TickTick auth failed: state mismatch. Please try connecting again.');
|
|
return;
|
|
}
|
|
await this.exchangeAuthCodeForToken(params.code);
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'create-ticktick-task',
|
|
name: 'Create TickTick task',
|
|
icon: 'lucide-circle-check-big',
|
|
editorCallback: (editor: Editor, view: MarkdownView) => this.createTaskFromEditor(editor, view)
|
|
});
|
|
|
|
this.registerEvent(
|
|
this.app.workspace.on('file-menu', (menu: Menu, file) => {
|
|
if (!(file instanceof TFile) || file.extension !== 'md') return;
|
|
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
|
|
if (!view || !view.editor || view.file !== file) return;
|
|
|
|
menu.addItem(item => {
|
|
item
|
|
.setTitle('Create TickTick task')
|
|
.setIcon('lucide-circle-check-big')
|
|
.onClick(async () => {
|
|
await this.createTaskFromEditor(view.editor, view);
|
|
});
|
|
});
|
|
})
|
|
);
|
|
}
|
|
|
|
async createTaskFromEditor(editor: Editor, view: MarkdownView): Promise<void> {
|
|
const cursor = editor.getCursor();
|
|
if (!view.file) {
|
|
new Notice("No file found for the current view!");
|
|
return;
|
|
}
|
|
|
|
let taskText: string;
|
|
let rangeStart: number;
|
|
let rangeEnd: number;
|
|
if (this.settings.selectionMode === 'paragraph') {
|
|
const { text, start, end } = getParagraph(editor, cursor.line);
|
|
taskText = text;
|
|
rangeStart = start;
|
|
rangeEnd = end;
|
|
} else {
|
|
const { text, line } = getCurrentLine(editor, cursor.line);
|
|
taskText = text;
|
|
rangeStart = line;
|
|
rangeEnd = line;
|
|
}
|
|
|
|
if (!taskText.trim()) {
|
|
new Notice("No text found on the current line!");
|
|
return;
|
|
}
|
|
|
|
const blockId = generateRandomString(8);
|
|
const updatedText = this.settings.tagPosition === 'prepend'
|
|
? `#ticktick ${taskText} ^${blockId}`
|
|
: `${taskText} #ticktick ^${blockId}`;
|
|
editor.replaceRange(updatedText, { line: rangeStart, ch: 0 }, { line: rangeEnd, ch: editor.getLine(rangeEnd).length });
|
|
|
|
const vaultName = this.app.vault.getName();
|
|
const filePath = view.file.path;
|
|
const advancedUri = `obsidian://advanced-uri?vault=${encodeURIComponent(vaultName)}&filepath=${encodeURIComponent(filePath)}&block=${encodeURIComponent(blockId)}`;
|
|
const taskDescription = `${taskText}\n\n[Open in Obsidian](${advancedUri})`;
|
|
const cleanTitle = stripMarkdown(taskText);
|
|
const taskTitle = cleanTitle.length > 50 ? cleanTitle.substring(0, 50) + "..." : cleanTitle;
|
|
|
|
await this.ensureFreshToken();
|
|
|
|
try {
|
|
const result = await this.createTicktickTask(taskTitle, taskDescription);
|
|
if (result.success) {
|
|
new Notice("TickTick task created successfully!");
|
|
} else {
|
|
new Notice("Failed to create TickTick task.");
|
|
}
|
|
} catch (error) {
|
|
log(this, 'task_creation_failed', { error: error.message });
|
|
new Notice(`Failed to create task: ${error.message}\nCheck console for details.`);
|
|
}
|
|
}
|
|
|
|
onunload() {
|
|
console.log('Unloading TickTick Plugin');
|
|
}
|
|
|
|
/**
|
|
* Initiates the OAuth flow by generating PKCE codes and opening the authorization URL.
|
|
* Uses the redirect URI from settings (default is OOB).
|
|
*/
|
|
async startAuthFlow(): Promise<void> {
|
|
const clientId = this.settings.clientId;
|
|
if (!clientId) {
|
|
new Notice('Please enter your Client ID in the settings.');
|
|
return;
|
|
}
|
|
const redirectUri = (this.settings.redirectUri || 'https://ticktick-quick-add-obsidian-6yawfmvnj-mooshs-projects-0635287d.vercel.app').trim().replace(/\/+$/, '');
|
|
const scope = encodeURIComponent('tasks:read tasks:write');
|
|
const { codeVerifier, codeChallenge } = await generatePKCECodes();
|
|
const state = generateRandomString(32);
|
|
this.settings.tempCodeVerifier = codeVerifier;
|
|
this.settings.tempState = state;
|
|
await this.saveSettings();
|
|
const authUrl = `https://ticktick.com/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${scope}&code_challenge=${codeChallenge}&code_challenge_method=S256&state=${state}`;
|
|
new AuthLinkModal(this.app, authUrl).open();
|
|
}
|
|
|
|
/**
|
|
* Exchanges an authorization code for an access token.
|
|
* Stores access token, refresh token, and token expiry.
|
|
*/
|
|
async exchangeAuthCodeForToken(code: string): Promise<void> {
|
|
const tokenEndpoint = 'https://ticktick.com/oauth/token';
|
|
const redirectUri = (this.settings.redirectUri || 'https://ticktick-quick-add-obsidian-6yawfmvnj-mooshs-projects-0635287d.vercel.app').trim().replace(/\/+$/, '');
|
|
const clientId = this.settings.clientId;
|
|
const clientSecret = this.settings.clientSecret;
|
|
const codeVerifier = this.settings.tempCodeVerifier;
|
|
if (!codeVerifier || !clientId || !clientSecret) {
|
|
new Notice('Missing required credentials. Please update your settings.');
|
|
return;
|
|
}
|
|
const params = new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code: code,
|
|
redirect_uri: redirectUri,
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
code_verifier: codeVerifier
|
|
});
|
|
try {
|
|
const response = await requestUrl({
|
|
url: tokenEndpoint,
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' // ADDED USER AGENT
|
|
},
|
|
body: params.toString()
|
|
});
|
|
if (response.status === 200) {
|
|
const data = response.json;
|
|
|
|
// MODIFICATION 2: CHECK FOR AND STORE REFRESH TOKEN
|
|
if (data.refresh_token) {
|
|
this.settings.refreshToken = data.refresh_token;
|
|
} else {
|
|
console.warn("Refresh token not received during token exchange.");
|
|
new Notice("Refresh token not received. Re-authentication may be required more often.");
|
|
}
|
|
|
|
this.settings.accessToken = data.access_token;
|
|
this.settings.tokenExpiry = Date.now() + (data.expires_in * 1000 * 0.85);
|
|
this.settings.tempCodeVerifier = undefined;
|
|
this.settings.tempState = undefined;
|
|
await this.saveSettings();
|
|
new Notice('TickTick access token obtained successfully!');
|
|
} else {
|
|
console.error("Token exchange error (status):", response.status);
|
|
console.log("response.json:", response.json);
|
|
console.log("response.text:", response.text);
|
|
new Notice('Failed to obtain access token.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error during token exchange:', error);
|
|
new Notice('Error during token exchange.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refreshes the access token using the stored refresh token.
|
|
*/
|
|
async refreshAccessToken(): Promise<void> {
|
|
if (!this.settings.refreshToken) {
|
|
throw new Error('No refresh token available. Please reconnect.');
|
|
}
|
|
const tokenEndpoint = 'https://ticktick.com/oauth/token';
|
|
const clientId = this.settings.clientId;
|
|
const clientSecret = this.settings.clientSecret;
|
|
const params = new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: this.settings.refreshToken,
|
|
client_id: clientId,
|
|
client_secret: clientSecret
|
|
});
|
|
try {
|
|
const response = await requestUrl({
|
|
url: tokenEndpoint,
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', // ADDED USER AGENT
|
|
},
|
|
body: params.toString()
|
|
});
|
|
if (response.status === 200) {
|
|
const data = response.json;
|
|
|
|
// MODIFICATION 3: UPDATE REFRESH TOKEN IF A NEW ONE IS RETURNED (RARE BUT POSSIBLE)
|
|
if (data.refresh_token) {
|
|
this.settings.refreshToken = data.refresh_token;
|
|
}
|
|
|
|
this.settings.accessToken = data.access_token;
|
|
this.settings.tokenExpiry = Date.now() + (data.expires_in * 1000 * 0.85);
|
|
await this.saveSettings();
|
|
new Notice('Access token refreshed successfully!');
|
|
} else {
|
|
console.error('Refresh token error:', response.text);
|
|
throw new Error('Failed to refresh token');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error refreshing access token:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensures that the access token is fresh. If expired, refreshes it automatically.
|
|
*/
|
|
async ensureFreshToken(): Promise<void> {
|
|
if (this.settings.tokenExpiry && Date.now() > this.settings.tokenExpiry) {
|
|
log(this, 'token_expired', { tokenExpiry: this.settings.tokenExpiry });
|
|
try {
|
|
await this.refreshAccessToken();
|
|
} catch (error) {
|
|
new Notice('Reauthentication required. Please reconnect to TickTick.');
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a task in TickTick by sending a POST request to the open/v1 endpoint.
|
|
*/
|
|
async createTicktickTask(title: string, description: string): Promise<{ success: boolean }> {
|
|
const accessToken = this.settings.accessToken;
|
|
if (!accessToken) {
|
|
new Notice('No access token found. Please connect to TickTick in the settings.');
|
|
return { success: false };
|
|
}
|
|
const endpoint = 'https://api.ticktick.com/open/v1/task';
|
|
const payload = { title, content: description };
|
|
try {
|
|
log(this, 'task_creation_request', { endpoint, title });
|
|
const response = await requestUrl({
|
|
url: endpoint,
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', // ADDED USER AGENT
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (response.status === 200) {
|
|
return { success: true };
|
|
} else {
|
|
console.error("TickTick API error (status):", response.status);
|
|
console.log("response.json:", response.json);
|
|
console.log("response.text:", response.text);
|
|
return { success: false };
|
|
}
|
|
} catch (error) {
|
|
console.error("TickTick API error:", error);
|
|
return { success: false };
|
|
}
|
|
}
|
|
|
|
// Methods to load and save settings for persistence
|
|
async loadSettings() {
|
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
|
}
|
|
|
|
async saveSettings() {
|
|
await this.saveData(this.settings);
|
|
}
|
|
}
|