mirror of
https://github.com/heymoosh/ticktick-quick-add-obsidian.git
synced 2026-07-22 05:43:55 +00:00
Patch - Server settings, PR file name, compiled code
Updated server settings to use temporary local server Pull request file name updated for PCs Remove compiled main.js files
This commit is contained in:
parent
87853452b4
commit
3e4035fb54
5 changed files with 332 additions and 323 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -16,3 +16,8 @@ data.json
|
|||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
|
||||
# .gitignore addition
|
||||
/main.js
|
||||
*.js.map
|
||||
/dist/
|
||||
|
|
|
|||
36
README.md
36
README.md
|
|
@ -8,8 +8,9 @@ The **TickTick Quickadd Plugin** lets you quickly create tasks in TickTick direc
|
|||
## Features
|
||||
|
||||
- **Quick Task Creation:** Convert a selected paragraph into a TickTick task with one command.
|
||||
- **Direct Linking:** Generate an Advanced URI link that takes you right back to the exact block in your note.
|
||||
- **Secure OAuth Integration:** Uses TickTick’s OAuth with PKCE for secure authentication and supports token auto-refresh.
|
||||
- **Direct Linking:** Generate an Advanced URI link that takes you right back to the exact block in your note (separate community plugin installation required).
|
||||
- **Secure OAuth Integration:** Uses TickTick's OAuth with PKCE for secure authentication and supports automatic token refresh.
|
||||
- **Automated Authentication:** Uses a temporary local server during the OAuth flow to automatically capture the callback, so you don’t have to copy and paste the authorization code manually.
|
||||
- **User-Friendly Settings:** Enter your TickTick API credentials securely and connect with just a few clicks.
|
||||
|
||||
## Installation
|
||||
|
|
@ -26,7 +27,7 @@ The **TickTick Quickadd Plugin** lets you quickly create tasks in TickTick direc
|
|||
npm install
|
||||
npm run build
|
||||
```
|
||||
- Copy the generated files (e.g., `main.js`, `manifest.json`, and `settings.js`) into your vault’s plugins folder:
|
||||
- Copy the generated files (e.g., `main.js`, `manifest.json`, and `settings.js`) into your vault's plugins folder:
|
||||
```
|
||||
YourVault/.obsidian/plugins/ticktick-quickadd-plugin/
|
||||
```
|
||||
|
|
@ -37,20 +38,20 @@ The **TickTick Quickadd Plugin** lets you quickly create tasks in TickTick direc
|
|||
1. **Configure API Credentials:**
|
||||
- Open the plugin settings in Obsidian (Settings → Community Plugins → TickTick Quickadd Plugin → Settings).
|
||||
- Enter your **Client ID** and **Client Secret**.
|
||||
(To get these, sign in to the [TickTick Developer Portal](https://developer.ticktick.com/) and follow their “Get Started” instructions.)
|
||||
(To get these, sign in to the [TickTick Developer Portal](https://developer.ticktick.com/) and follow their "Get Started" instructions.)
|
||||
- **Important:** Make sure to add `http://127.0.0.1:3000/callback` as an allowed redirect URI in your TickTick Developer application settings.
|
||||
- **Note:** Your Client Secret input is masked for security.
|
||||
|
||||
2. **Connect to TickTick:**
|
||||
- In the settings, click **Connect to TickTick**.
|
||||
- The plugin will start a temporary local server on port 3000 to handle the OAuth callback.
|
||||
- Your browser will open the OAuth authorization URL. Log in to TickTick and authorize the plugin.
|
||||
- After authorizing, you'll be directed to a page with an authorization code.
|
||||
- **Tip:** It might take more than one attempt to retrieve the code. (This can happen due to network timing or OAuth quirks.)
|
||||
- Copy the authorization code and paste it into the **Authorization Code** field in the plugin settings.
|
||||
- Again, you might need to try a couple of times before it works.
|
||||
- Once connected, you’ll receive a notice that the access token was obtained successfully.
|
||||
- After authorizing, you'll be automatically redirected to the local callback server, which will capture the authorization code and exchange it for an access token.
|
||||
- Once connected, you'll receive a notice that the access token was obtained successfully.
|
||||
- **Note:** If you have a firewall, you may need to allow access to the temporary local server on port 3000.
|
||||
|
||||
3. **Configure Hotkeys:**
|
||||
- In Obsidian’s **Settings → Hotkeys**, scroll down to your TickTick Quickadd Plugin.
|
||||
- In Obsidian's **Settings → Hotkeys**, scroll down to your TickTick Quickadd Plugin.
|
||||
- Assign a keyboard shortcut (e.g., Ctrl+Alt+T) to the command **"Create TickTick Task from Paragraph"**.
|
||||
|
||||
## Using the Plugin
|
||||
|
|
@ -65,6 +66,12 @@ The **TickTick Quickadd Plugin** lets you quickly create tasks in TickTick direc
|
|||
|
||||
## Troubleshooting & Testing
|
||||
|
||||
- **Port Already in Use:**
|
||||
If port 3000 is already in use by another application, the OAuth flow will fail. Close any applications using port 3000 and try again.
|
||||
|
||||
- **Firewall Blocking:**
|
||||
If your firewall is blocking the temporary local server, you may need to allow access to port 3000 during the OAuth flow.
|
||||
|
||||
- **Invalid Credential Handling:**
|
||||
If you enter an invalid Client ID or Secret, the OAuth flow will fail. Make sure your credentials are correct and match those in the TickTick Developer Portal.
|
||||
|
||||
|
|
@ -82,22 +89,21 @@ The **TickTick Quickadd Plugin** lets you quickly create tasks in TickTick direc
|
|||
|
||||
## Known Issues
|
||||
|
||||
- **OAuth Flow Hiccups:**
|
||||
It might take more than one attempt to successfully retrieve your authorization code and access token. Recommend retrying if you encounter an error.
|
||||
|
||||
- **Settings Persistence:**
|
||||
Credentials (Client ID, Client Secret, tokens) are stored using Obsidian’s storage. If you uninstall and reinstall the plugin, these settings are cleared.
|
||||
Credentials (Client ID, Client Secret, tokens) are stored using Obsidian's storage. If you uninstall and reinstall the plugin, these settings are cleared.
|
||||
|
||||
## Code Quality
|
||||
|
||||
- This plugin is written in TypeScript with structured logging and robust error handling.
|
||||
- All sensitive credentials are provided by the user through the settings UI—no hardcoded secrets are published.
|
||||
|
||||
- The temporary local server is only active during the OAuth flow and automatically closes after completion.
|
||||
|
||||
## Security:
|
||||
- The plugin does not include any hardcoded credentials.
|
||||
- All sensitive data is entered by the user through the settings UI.
|
||||
- All data remains local
|
||||
- The temporary server only runs during authentication and immediately closes afterward
|
||||
- OAuth state validation is implemented to prevent CSRF attacks
|
||||
- No tracking/analytics
|
||||
|
||||
## License
|
||||
|
|
|
|||
8
main.js
8
main.js
|
|
@ -3,8 +3,8 @@ THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
|||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
|
||||
var T=Object.defineProperty;var P=Object.getOwnPropertyDescriptor;var b=Object.getOwnPropertyNames;var I=Object.prototype.hasOwnProperty;var E=(s,i)=>{for(var e in i)T(s,e,{get:i[e],enumerable:!0})},_=(s,i,e,t)=>{if(i&&typeof i=="object"||typeof i=="function")for(let n of b(i))!I.call(s,n)&&n!==e&&T(s,n,{get:()=>i[n],enumerable:!(t=P(i,n))||t.enumerable});return s};var A=s=>_(T({},"__esModule",{value:!0}),s);var R={};E(R,{default:()=>k});module.exports=A(R);var r=require("obsidian");var l=require("obsidian"),m={accessToken:"",clientId:"",clientSecret:""},u=class extends l.PluginSettingTab{constructor(e,t){super(e,t);this.plugin=t}display(){let{containerEl:e}=this;e.empty(),e.createEl("h2",{text:"TickTick API Settings"}),new l.Setting(e).setName("Client ID").setDesc("Enter your TickTick Client ID from the Developer Console.").addText(t=>t.setPlaceholder("Your Client ID").setValue(this.plugin.settings.clientId||"").onChange(async n=>{this.plugin.settings.clientId=n.trim(),await this.plugin.saveSettings()})),new l.Setting(e).setName("Client Secret").setDesc("Enter your TickTick Client Secret (input is masked).").addText(t=>(t.inputEl.type="password",t.setPlaceholder("\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022").setValue(this.plugin.settings.clientSecret||"").onChange(async n=>{this.plugin.settings.clientSecret=n.trim(),await this.plugin.saveSettings()}),t)),new l.Setting(e).setName("Access Token").setDesc("Your current access token. (Reauthenticate if expired.)").addText(t=>t.setPlaceholder("Access token will appear here").setValue(this.plugin.settings.accessToken||"").onChange(async n=>{this.plugin.settings.accessToken=n.trim(),await this.plugin.saveSettings()})),new l.Setting(e).setName("Refresh Token").setDesc("Stored refresh token (for debugging).").addText(t=>t.setPlaceholder("Refresh token").setValue(this.plugin.settings.refreshToken||"").onChange(async n=>{this.plugin.settings.refreshToken=n.trim(),await this.plugin.saveSettings()})),new l.Setting(e).setName("Connect to TickTick").setDesc("Click to open the OAuth authorization URL in your browser.").addButton(async t=>{t.setButtonText("Connect").onClick(async()=>{if(!this.plugin.settings.clientId){new l.Notice("Please enter your Client ID first.");return}await this.plugin.startAuthFlow()})}),new l.Setting(e).setName("Authorization Code").setDesc("Paste the authorization code from TickTick here to obtain an access token.").addText(t=>t.setPlaceholder("Enter authorization code").onChange(async n=>{n.trim()&&(await this.plugin.exchangeAuthCodeForToken(n.trim()),t.setValue(""),this.display())}))}};function w(s){let i="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789",e="";for(let t=0;t<s;t++)e+=i.charAt(Math.floor(Math.random()*i.length));return e}async function v(s){let e=new TextEncoder().encode(s);return await crypto.subtle.digest("SHA-256",e)}function D(s){let i=new Uint8Array(s),e="";return i.forEach(t=>e+=String.fromCharCode(t)),btoa(e).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}async function N(){let s=w(64),i=await v(s),e=D(i);return{codeVerifier:s,codeChallenge:e}}function U(s,i){let e=i,t=i,n=s.lineCount();for(;e>0&&s.getLine(e-1).trim()!=="";)e--;for(;t<n-1&&s.getLine(t+1).trim()!=="";)t++;let o="";for(let c=e;c<=t;c++)o+=s.getLine(c)+`
|
||||
`;return{text:o.trim(),start:e,end:t}}function f(s,i,e){console.log(JSON.stringify({timestamp:new Date().toISOString(),plugin:"ticktick-integration",action:i,...e,clientId:s.settings.clientId?"***":void 0,accessToken:s.settings.accessToken?"***":void 0}))}var k=class extends r.Plugin{async onload(){console.log("Loading TickTick Plugin"),await this.loadSettings(),this.addSettingTab(new u(this.app,this)),this.addCommand({id:"create-ticktick-task",name:"Create TickTick Task from Paragraph",editorCallback:async(e,t)=>{let n=e.getCursor();if(!t.file){new r.Notice("No file found for the current view!");return}let{text:o,start:c,end:a}=U(e,n.line);if(!o){new r.Notice("No paragraph text found!");return}let d=w(8),h=`#ticktick ${o} ^${d}`;e.replaceRange(h,{line:c,ch:0},{line:a,ch:e.getLine(a).length});let g=this.app.vault.getName(),y=t.file.path,S=`obsidian://advanced-uri?vault=${encodeURIComponent(g)}&filepath=${encodeURIComponent(y)}&block=${encodeURIComponent(d)}`,C=`${o}
|
||||
var T=Object.defineProperty;var b=Object.getOwnPropertyDescriptor;var P=Object.getOwnPropertyNames;var _=Object.prototype.hasOwnProperty;var E=(s,i)=>{for(var e in i)T(s,e,{get:i[e],enumerable:!0})},I=(s,i,e,t)=>{if(i&&typeof i=="object"||typeof i=="function")for(let n of P(i))!_.call(s,n)&&n!==e&&T(s,n,{get:()=>i[n],enumerable:!(t=b(i,n))||t.enumerable});return s};var U=s=>I(T({},"__esModule",{value:!0}),s);var N={};E(N,{default:()=>k});module.exports=U(N);var r=require("obsidian");var l=require("obsidian"),m={accessToken:"",clientId:"",clientSecret:"",redirectUri:"urn:ietf:wg:oauth:2.0:oob"},u=class extends l.PluginSettingTab{constructor(e,t){super(e,t);this.plugin=t}display(){let{containerEl:e}=this;e.empty(),e.createEl("h2",{text:"TickTick API Settings"}),new l.Setting(e).setName("Client ID").setDesc("Enter your TickTick Client ID from the Developer Console.").addText(t=>t.setPlaceholder("Your Client ID").setValue(this.plugin.settings.clientId||"").onChange(async n=>{this.plugin.settings.clientId=n.trim(),await this.plugin.saveSettings()})),new l.Setting(e).setName("Client Secret").setDesc("Enter your TickTick Client Secret (input is masked).").addText(t=>(t.inputEl.type="password",t.setPlaceholder("\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022").setValue(this.plugin.settings.clientSecret||"").onChange(async n=>{this.plugin.settings.clientSecret=n.trim(),await this.plugin.saveSettings()}),t)),new l.Setting(e).setName("Redirect URI").setDesc("Enter the Redirect URI registered in your TickTick Developer Portal. (Default: urn:ietf:wg:oauth:2.0:oob)").addText(t=>t.setPlaceholder("urn:ietf:wg:oauth:2.0:oob").setValue(this.plugin.settings.redirectUri||"").onChange(async n=>{this.plugin.settings.redirectUri=n.trim(),await this.plugin.saveSettings()})),new l.Setting(e).setName("Access Token").setDesc("Your current access token (read-only).").addText(t=>t.setPlaceholder("Access token will appear here").setValue(this.plugin.settings.accessToken||"").onChange(async n=>{this.plugin.settings.accessToken=n.trim(),await this.plugin.saveSettings()})),new l.Setting(e).setName("Refresh Token").setDesc("Stored refresh token (for debugging purposes).").addText(t=>t.setPlaceholder("Refresh token").setValue(this.plugin.settings.refreshToken||"").onChange(async n=>{this.plugin.settings.refreshToken=n.trim(),await this.plugin.saveSettings()})),new l.Setting(e).setName("Connect to TickTick").setDesc("Click to open the OAuth authorization URL in your browser.").addButton(async t=>{t.setButtonText("Connect").onClick(async()=>{await this.plugin.startAuthFlow()})}),new l.Setting(e).setName("Authorization Code").setDesc("Paste the authorization code from TickTick here to obtain an access token.").addText(t=>t.setPlaceholder("Enter authorization code").onChange(async n=>{n.trim()&&(await this.plugin.exchangeAuthCodeForToken(n.trim()),t.setValue(""),this.display())}))}};function w(s){let i="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789",e="";for(let t=0;t<s;t++)e+=i.charAt(Math.floor(Math.random()*i.length));return e}function A(s,i){let e=i,t=i,n=s.lineCount();for(;e>0&&s.getLine(e-1).trim()!=="";)e--;for(;t<n-1&&s.getLine(t+1).trim()!=="";)t++;let c="";for(let a=e;a<=t;a++)c+=s.getLine(a)+`
|
||||
`;return{text:c.trim(),start:e,end:t}}function v(s){let i=new Uint8Array(s),e="";return i.forEach(t=>e+=String.fromCharCode(t)),btoa(e).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}async function D(){let s=w(64),i=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(s)),e=v(i);return{codeVerifier:s,codeChallenge:e}}function f(s,i,e){console.log(JSON.stringify({timestamp:new Date().toISOString(),plugin:"ticktick-integration",action:i,...e,clientId:s.settings.clientId?"***":void 0,accessToken:s.settings.accessToken?"***":void 0}))}var k=class extends r.Plugin{async onload(){console.log("Loading TickTick Plugin"),await this.loadSettings(),this.addSettingTab(new u(this.app,this)),this.addCommand({id:"create-ticktick-task",name:"Create TickTick Task from Paragraph",editorCallback:async(e,t)=>{let n=e.getCursor();if(!t.file){new r.Notice("No file found for the current view!");return}let{text:c,start:a,end:o}=A(e,n.line);if(!c){new r.Notice("No paragraph text found!");return}let g=w(8),h=`#ticktick ${c} ^${g}`;e.replaceRange(h,{line:a,ch:0},{line:o,ch:e.getLine(o).length});let d=this.app.vault.getName(),y=t.file.path,S=`obsidian://advanced-uri?vault=${encodeURIComponent(d)}&filepath=${encodeURIComponent(y)}&block=${encodeURIComponent(g)}`,x=`${c}
|
||||
|
||||
[Open in Obsidian](${S})`,x=o.length>50?o.substring(0,50)+"...":o;await this.ensureFreshToken();try{(await this.createTicktickTask(x,C)).success?new r.Notice("TickTick task created successfully!"):new r.Notice("Failed to create TickTick task.")}catch(p){f(this,"task_creation_failed",{error:p.message}),new r.Notice(`Failed to create task: ${p.message}
|
||||
Check console for details.`)}}})}onunload(){console.log("Unloading TickTick Plugin")}async startAuthFlow(){let e=this.settings.clientId;if(!e){new r.Notice("Please enter your Client ID in the settings.");return}let t="http://127.0.0.1:3000/callback",n=encodeURIComponent("tasks:read tasks:write"),{codeVerifier:o,codeChallenge:c}=await N(),a=w(32);this.settings.tempCodeVerifier=o,this.settings.tempState=a,await this.saveSettings();let d=`https://ticktick.com/oauth/authorize?client_id=${e}&redirect_uri=${encodeURIComponent(t)}&response_type=code&scope=${n}&code_challenge=${c}&code_challenge_method=S256&state=${a}`;window.open(d,"_blank"),new r.Notice("OAuth flow initiated. Please complete it in your browser.")}async exchangeAuthCodeForToken(e){let t="https://ticktick.com/oauth/token",n="http://127.0.0.1:3000/callback",o=this.settings.clientId,c=this.settings.clientSecret,a=this.settings.tempCodeVerifier;if(!a||!o||!c){new r.Notice("Missing required credentials. Please update your settings.");return}let d=new URLSearchParams({grant_type:"authorization_code",code:e,redirect_uri:n,client_id:o,client_secret:c,code_verifier:a});try{let h=await(0,r.requestUrl)({url:t,method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"},body:d.toString()});if(h.status===200){let g=h.json;this.settings.accessToken=g.access_token,this.settings.refreshToken=g.refresh_token,this.settings.tokenExpiry=Date.now()+g.expires_in*1e3*.85,await this.saveSettings(),new r.Notice("TickTick access token obtained successfully!")}else console.error("Token exchange error (status):",h.status),console.log("response.json:",h.json),console.log("response.text:",h.text),new r.Notice("Failed to obtain access token.")}catch(h){console.error("Error during token exchange:",h),new r.Notice("Error during token exchange.")}}async refreshAccessToken(){if(!this.settings.refreshToken)throw new Error("No refresh token available. Please reconnect.");let e="https://ticktick.com/oauth/token",t=this.settings.clientId,n=this.settings.clientSecret,o=new URLSearchParams({grant_type:"refresh_token",refresh_token:this.settings.refreshToken,client_id:t,client_secret:n});try{let c=await(0,r.requestUrl)({url:e,method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","X-Requested-With":"XMLHttpRequest"},body:o.toString()});if(c.status===200){let a=c.json;this.settings.accessToken=a.access_token,this.settings.refreshToken=a.refresh_token,this.settings.tokenExpiry=Date.now()+a.expires_in*1e3*.85,await this.saveSettings(),new r.Notice("Access token refreshed successfully!")}else throw console.error("Refresh token error:",c.text),new Error("Failed to refresh token")}catch(c){throw console.error("Error refreshing access token:",c),c}}async ensureFreshToken(){if(this.settings.tokenExpiry&&Date.now()>this.settings.tokenExpiry){f(this,"token_expired",{tokenExpiry:this.settings.tokenExpiry});try{await this.refreshAccessToken()}catch(e){throw new r.Notice("Reauthentication required. Please reconnect to TickTick."),e}}}async createTicktickTask(e,t){let n=this.settings.accessToken;if(!n)return new r.Notice("No access token found. Please connect to TickTick in the settings."),{success:!1};let o="https://api.ticktick.com/open/v1/task",c={title:e,content:t};try{f(this,"task_creation_request",{endpoint:o,title:e});let a=await(0,r.requestUrl)({url:o,method:"POST",headers:{Authorization:`Bearer ${n}`,"Content-Type":"application/json"},body:JSON.stringify(c)});return a.status===200?{success:!0}:(console.error("TickTick API error (status):",a.status),console.log("response.json:",a.json),console.log("response.text:",a.text),{success:!1})}catch(a){return console.error("TickTick API error:",a),{success:!1}}}async loadSettings(){this.settings=Object.assign({},m,await this.loadData())}async saveSettings(){await this.saveData(this.settings)}};
|
||||
[Open in Obsidian](${S})`,C=c.length>50?c.substring(0,50)+"...":c;await this.ensureFreshToken();try{(await this.createTicktickTask(C,x)).success?new r.Notice("TickTick task created successfully!"):new r.Notice("Failed to create TickTick task.")}catch(p){f(this,"task_creation_failed",{error:p.message}),new r.Notice(`Failed to create task: ${p.message}
|
||||
Check console for details.`)}}})}onunload(){console.log("Unloading TickTick Plugin")}async startAuthFlow(){let e=this.settings.clientId;if(!e){new r.Notice("Please enter your Client ID in the settings.");return}let t=this.settings.redirectUri||"urn:ietf:wg:oauth:2.0:oob",n=encodeURIComponent("tasks:read tasks:write"),{codeVerifier:c,codeChallenge:a}=await D(),o=w(32);this.settings.tempCodeVerifier=c,this.settings.tempState=o,await this.saveSettings();let g=`https://ticktick.com/oauth/authorize?client_id=${e}&redirect_uri=${encodeURIComponent(t)}&response_type=code&scope=${n}&code_challenge=${a}&code_challenge_method=S256&state=${o}`;window.open(g,"_blank"),new r.Notice("OAuth flow initiated. Please complete it in your browser.")}async exchangeAuthCodeForToken(e){let t="https://ticktick.com/oauth/token",n=this.settings.redirectUri||"urn:ietf:wg:oauth:2.0:oob",c=this.settings.clientId,a=this.settings.clientSecret,o=this.settings.tempCodeVerifier;if(!o||!c||!a){new r.Notice("Missing required credentials. Please update your settings.");return}let g=new URLSearchParams({grant_type:"authorization_code",code:e,redirect_uri:n,client_id:c,client_secret:a,code_verifier:o});try{let h=await(0,r.requestUrl)({url:t,method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"},body:g.toString()});if(h.status===200){let d=h.json;d.refresh_token?this.settings.refreshToken=d.refresh_token:(console.warn("Refresh token not received during token exchange."),new r.Notice("Refresh token not received. Re-authentication may be required more often.")),this.settings.accessToken=d.access_token,this.settings.tokenExpiry=Date.now()+d.expires_in*1e3*.85,await this.saveSettings(),new r.Notice("TickTick access token obtained successfully!")}else console.error("Token exchange error (status):",h.status),console.log("response.json:",h.json),console.log("response.text:",h.text),new r.Notice("Failed to obtain access token.")}catch(h){console.error("Error during token exchange:",h),new r.Notice("Error during token exchange.")}}async refreshAccessToken(){if(!this.settings.refreshToken)throw new Error("No refresh token available. Please reconnect.");let e="https://ticktick.com/oauth/token",t=this.settings.clientId,n=this.settings.clientSecret,c=new URLSearchParams({grant_type:"refresh_token",refresh_token:this.settings.refreshToken,client_id:t,client_secret:n});try{let a=await(0,r.requestUrl)({url:e,method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"},body:c.toString()});if(a.status===200){let o=a.json;o.refresh_token&&(this.settings.refreshToken=o.refresh_token),this.settings.accessToken=o.access_token,this.settings.tokenExpiry=Date.now()+o.expires_in*1e3*.85,await this.saveSettings(),new r.Notice("Access token refreshed successfully!")}else throw console.error("Refresh token error:",a.text),new Error("Failed to refresh token")}catch(a){throw console.error("Error refreshing access token:",a),a}}async ensureFreshToken(){if(this.settings.tokenExpiry&&Date.now()>this.settings.tokenExpiry){f(this,"token_expired",{tokenExpiry:this.settings.tokenExpiry});try{await this.refreshAccessToken()}catch(e){throw new r.Notice("Reauthentication required. Please reconnect to TickTick."),e}}}async createTicktickTask(e,t){let n=this.settings.accessToken;if(!n)return new r.Notice("No access token found. Please connect to TickTick in the settings."),{success:!1};let c="https://api.ticktick.com/open/v1/task",a={title:e,content:t};try{f(this,"task_creation_request",{endpoint:c,title:e});let o=await(0,r.requestUrl)({url:c,method:"POST",headers:{Authorization:`Bearer ${n}`,"Content-Type":"application/json","User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"},body:JSON.stringify(a)});return o.status===200?{success:!0}:(console.error("TickTick API error (status):",o.status),console.log("response.json:",o.json),console.log("response.text:",o.text),{success:!1})}catch(o){return console.error("TickTick API error:",o),{success:!1}}}async loadSettings(){this.settings=Object.assign({},m,await this.loadData())}async saveSettings(){await this.saveData(this.settings)}};
|
||||
|
|
|
|||
566
main.ts
566
main.ts
|
|
@ -1,319 +1,315 @@
|
|||
import { App, Editor, MarkdownView, Notice, Plugin, requestUrl } from 'obsidian';
|
||||
import { TickTickSettingTab, TickTickSettings, DEFAULT_SETTINGS } from './settings';
|
||||
|
||||
// Helper function to generate a random alphanumeric string (used for block anchors and state)
|
||||
// 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 compute SHA-256 hash of a string (returns ArrayBuffer)
|
||||
async function sha256(plain: string): Promise<ArrayBuffer> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(plain);
|
||||
return await crypto.subtle.digest('SHA-256', data);
|
||||
}
|
||||
|
||||
// Helper function to base64url-encode an ArrayBuffer
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
bytes.forEach(b => (binary += String.fromCharCode(b)));
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
// Helper function to generate PKCE codes: a code_verifier and its corresponding code_challenge
|
||||
async function generatePKCECodes(): Promise<{ codeVerifier: string; codeChallenge: string }> {
|
||||
const codeVerifier = generateRandomString(64); // between 43-128 characters
|
||||
const hashBuffer = await sha256(codeVerifier);
|
||||
const codeChallenge = base64UrlEncode(hashBuffer);
|
||||
return { codeVerifier, codeChallenge };
|
||||
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 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 };
|
||||
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 };
|
||||
}
|
||||
|
||||
// Helper function to base64url-encode an ArrayBuffer
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
bytes.forEach(b => (binary += String.fromCharCode(b)));
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
// 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
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
|
||||
const codeChallenge = base64UrlEncode(hashBuffer);
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
// 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 fields
|
||||
clientId: plugin.settings.clientId ? '***' : undefined,
|
||||
accessToken: plugin.settings.accessToken ? '***' : undefined
|
||||
}));
|
||||
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;
|
||||
settings: TickTickSettings;
|
||||
|
||||
async onload() {
|
||||
console.log('Loading TickTick Plugin');
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new TickTickSettingTab(this.app, this));
|
||||
async onload() {
|
||||
console.log('Loading TickTick Plugin');
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new TickTickSettingTab(this.app, this));
|
||||
|
||||
// Register the command to create a TickTick task from a paragraph
|
||||
this.addCommand({
|
||||
id: 'create-ticktick-task',
|
||||
name: 'Create TickTick Task from Paragraph',
|
||||
editorCallback: async (editor: Editor, view: MarkdownView) => {
|
||||
const cursor = editor.getCursor();
|
||||
if (!view.file) {
|
||||
new Notice("No file found for the current view!");
|
||||
return;
|
||||
}
|
||||
const { text: paragraphText, start, end } = getParagraph(editor, cursor.line);
|
||||
if (!paragraphText) {
|
||||
new Notice("No paragraph text found!");
|
||||
return;
|
||||
}
|
||||
// Command: Create TickTick task from a paragraph
|
||||
this.addCommand({
|
||||
id: 'create-ticktick-task',
|
||||
name: 'Create TickTick Task from Paragraph',
|
||||
editorCallback: async (editor: Editor, view: MarkdownView) => {
|
||||
const cursor = editor.getCursor();
|
||||
if (!view.file) {
|
||||
new Notice("No file found for the current view!");
|
||||
return;
|
||||
}
|
||||
const { text: paragraphText, start, end } = getParagraph(editor, cursor.line);
|
||||
if (!paragraphText) {
|
||||
new Notice("No paragraph text found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate a unique block anchor automatically
|
||||
const blockId = generateRandomString(8);
|
||||
// Generate unique block anchor
|
||||
const blockId = generateRandomString(8);
|
||||
// Prepend "#ticktick" to the paragraph and append the block anchor
|
||||
const updatedParagraph = `#ticktick ${paragraphText} ^${blockId}`;
|
||||
editor.replaceRange(updatedParagraph, { line: start, ch: 0 }, { line: end, ch: editor.getLine(end).length });
|
||||
|
||||
// Prepend "#ticktick" to the paragraph and append the block anchor
|
||||
const updatedParagraph = `#ticktick ${paragraphText} ^${blockId}`;
|
||||
// Construct Advanced URI for the block
|
||||
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 = `${paragraphText}\n\n[Open in Obsidian](${advancedUri})`;
|
||||
const taskTitle = paragraphText.length > 50 ? paragraphText.substring(0, 50) + "..." : paragraphText;
|
||||
|
||||
// Update the note with the new paragraph
|
||||
editor.replaceRange(updatedParagraph, { line: start, ch: 0 }, { line: end, ch: editor.getLine(end).length });
|
||||
await this.ensureFreshToken();
|
||||
|
||||
// Construct the Advanced URI for the block
|
||||
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)}`;
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Build the TickTick task description with a clickable Markdown link
|
||||
const taskDescription = `${paragraphText}\n\n[Open in Obsidian](${advancedUri})`;
|
||||
onunload() {
|
||||
console.log('Unloading TickTick Plugin');
|
||||
}
|
||||
|
||||
const taskTitle = paragraphText.length > 50 ? paragraphText.substring(0, 50) + "..." : paragraphText;
|
||||
/**
|
||||
* 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 || 'urn:ietf:wg:oauth:2.0:oob';
|
||||
|
||||
// Ensure the access token is fresh before making the API call
|
||||
await this.ensureFreshToken();
|
||||
// MODIFICATION 1: REMOVED offline_access SCOPE - UNCERTAIN SUPPORT
|
||||
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}`;
|
||||
window.open(authUrl, '_blank');
|
||||
new Notice('OAuth flow initiated. Please complete it in your browser.');
|
||||
}
|
||||
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 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 || 'urn:ietf:wg:oauth:2.0:oob';
|
||||
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;
|
||||
|
||||
onunload() {
|
||||
console.log('Unloading TickTick Plugin');
|
||||
}
|
||||
// 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.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates the OAuth flow by generating PKCE codes and opening the authorization URL.
|
||||
*/
|
||||
async startAuthFlow(): Promise<void> {
|
||||
const clientId = this.settings.clientId;
|
||||
if (!clientId) {
|
||||
new Notice('Please enter your Client ID in the settings.');
|
||||
return;
|
||||
}
|
||||
const redirectUri = 'http://127.0.0.1:3000/callback';
|
||||
const scope = encodeURIComponent('tasks:read tasks:write');
|
||||
// Generate PKCE codes
|
||||
const { codeVerifier, codeChallenge } = await generatePKCECodes();
|
||||
// Generate a random state string
|
||||
const state = generateRandomString(32);
|
||||
// Save these values for later use during token exchange
|
||||
this.settings.tempCodeVerifier = codeVerifier;
|
||||
this.settings.tempState = state;
|
||||
await this.saveSettings();
|
||||
this.settings.accessToken = data.access_token;
|
||||
this.settings.tokenExpiry = Date.now() + (data.expires_in * 1000 * 0.85);
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
// Construct the OAuth authorization URL using the main domain
|
||||
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}`;
|
||||
// Open the authorization URL in the user's default browser
|
||||
window.open(authUrl, '_blank');
|
||||
new Notice('OAuth flow initiated. Please complete it in your browser.');
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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 = 'http://127.0.0.1:3000/callback';
|
||||
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)'
|
||||
},
|
||||
body: params.toString()
|
||||
});
|
||||
if (response.status === 200) {
|
||||
const data = response.json;
|
||||
this.settings.accessToken = data.access_token;
|
||||
this.settings.refreshToken = data.refresh_token;
|
||||
this.settings.tokenExpiry = Date.now() + (data.expires_in * 1000 * 0.85); // 15% buffer
|
||||
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.');
|
||||
}
|
||||
}
|
||||
// MODIFICATION 3: UPDATE REFRESH TOKEN IF A NEW ONE IS RETURNED (RARE BUT POSSIBLE)
|
||||
if (data.refresh_token) {
|
||||
this.settings.refreshToken = data.refresh_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: params.toString()
|
||||
});
|
||||
if (response.status === 200) {
|
||||
const data = response.json;
|
||||
this.settings.accessToken = data.access_token;
|
||||
this.settings.refreshToken = data.refresh_token; // Rotate refresh 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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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.
|
||||
* Uses the access token stored in settings.
|
||||
*/
|
||||
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'
|
||||
},
|
||||
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 };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
40
settings.ts
40
settings.ts
|
|
@ -4,9 +4,10 @@ import TickTickPlugin from './main';
|
|||
export interface TickTickSettings {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
tokenExpiry?: number; // timestamp in ms when the token expires
|
||||
tokenExpiry?: number;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri?: string; // New field for redirect URI
|
||||
tempCodeVerifier?: string;
|
||||
tempState?: string;
|
||||
}
|
||||
|
|
@ -14,7 +15,8 @@ export interface TickTickSettings {
|
|||
export const DEFAULT_SETTINGS: TickTickSettings = {
|
||||
accessToken: '',
|
||||
clientId: '',
|
||||
clientSecret: ''
|
||||
clientSecret: '',
|
||||
redirectUri: 'urn:ietf:wg:oauth:2.0:oob' // Default to OOB mode (no server needed)
|
||||
};
|
||||
|
||||
export class TickTickSettingTab extends PluginSettingTab {
|
||||
|
|
@ -57,25 +59,35 @@ export class TickTickSettingTab extends PluginSettingTab {
|
|||
return text;
|
||||
});
|
||||
|
||||
// Display current access token (read-only) for user info
|
||||
new Setting(containerEl)
|
||||
.setName('Redirect URI')
|
||||
.setDesc('Enter the Redirect URI registered in your TickTick Developer Portal. (Default: urn:ietf:wg:oauth:2.0:oob)')
|
||||
.addText(text =>
|
||||
text
|
||||
.setPlaceholder('urn:ietf:wg:oauth:2.0:oob')
|
||||
.setValue(this.plugin.settings.redirectUri || '')
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.redirectUri = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Access Token')
|
||||
.setDesc('Your current access token. (Reauthenticate if expired.)')
|
||||
.setDesc('Your current access token (read-only).')
|
||||
.addText(text =>
|
||||
text
|
||||
.setPlaceholder('Access token will appear here')
|
||||
.setValue(this.plugin.settings.accessToken || '')
|
||||
.onChange(async (value) => {
|
||||
// For production, this field is normally not edited manually.
|
||||
this.plugin.settings.accessToken = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
// Optionally display refresh token and expiry (for debugging; you may hide these in production)
|
||||
new Setting(containerEl)
|
||||
.setName('Refresh Token')
|
||||
.setDesc('Stored refresh token (for debugging).')
|
||||
.setDesc('Stored refresh token (for debugging purposes).')
|
||||
.addText(text =>
|
||||
text
|
||||
.setPlaceholder('Refresh token')
|
||||
|
|
@ -86,21 +98,11 @@ export class TickTickSettingTab extends PluginSettingTab {
|
|||
})
|
||||
);
|
||||
|
||||
// Add the Connect button and Authorization Code input as before
|
||||
new Setting(containerEl)
|
||||
.setName('Connect to TickTick')
|
||||
.setDesc('Click to open the OAuth authorization URL in your browser.')
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText('Connect').onClick(async () => {
|
||||
// (PKCE generation is handled in the plugin code; see main.ts for details.)
|
||||
// This Connect button will open the auth URL.
|
||||
const clientId = this.plugin.settings.clientId;
|
||||
if (!clientId) {
|
||||
new Notice('Please enter your Client ID first.');
|
||||
return;
|
||||
}
|
||||
// For simplicity, we assume PKCE and state generation are done in main.ts settings tab code.
|
||||
// Here, we simply call the plugin's startAuthFlow() method.
|
||||
await this.plugin.startAuthFlow();
|
||||
});
|
||||
});
|
||||
|
|
@ -114,8 +116,8 @@ export class TickTickSettingTab extends PluginSettingTab {
|
|||
.onChange(async (code) => {
|
||||
if (code.trim()) {
|
||||
await this.plugin.exchangeAuthCodeForToken(code.trim());
|
||||
text.setValue(''); // Clear the field after exchange
|
||||
this.display(); // Refresh the settings UI
|
||||
text.setValue('');
|
||||
this.display();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue