Log in to Pinboard
4 | 5 |You must log in to Pinboard first using the API token 6 | that you can find on your 7 | Pinboard password page.
8 | 9 | 34 | 35 |├── src ├── app │ ├── index.ts │ ├── app.component.ts │ ├── login │ │ ├── login.component.scss │ │ ├── login.component.html │ │ └── login.component.ts │ ├── options │ │ ├── options.component.scss │ │ ├── options.component.html │ │ └── options.component.ts │ ├── guard.ts │ ├── icon.service.ts │ ├── util.ts │ ├── interval.pipe.ts │ ├── storage.service.ts │ ├── pinpage │ │ ├── pinpage.component.scss │ │ ├── pinpage.component.html │ │ └── pinpage.component.ts │ ├── background │ │ └── background.component.ts │ └── pinboard.service.ts ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── img │ ├── pinboard_128.png │ ├── pinboard_16.png │ ├── pinboard_24.png │ ├── pinboard_256.png │ ├── pinboard_32.png │ ├── pinboard_48.png │ ├── pinboard_64.png │ ├── pinboard_96.png │ ├── pinboard_idle_128.png │ ├── pinboard_idle_16.png │ ├── pinboard_idle_24.png │ ├── pinboard_idle_256.png │ ├── pinboard_idle_32.png │ ├── pinboard_idle_48.png │ ├── pinboard_idle_64.png │ └── pinboard_idle_96.png ├── polyfills.ts ├── index.html ├── js │ └── content.js ├── main.ts ├── manifest.json └── styles.scss ├── .stylelintrc ├── screenshots ├── pinboard-pin-dark.png ├── pinboard-pin-light.png └── pinboard-pin-settings.png ├── tsconfig.app.json ├── .editorconfig ├── .browserslistrc ├── .gitignore ├── tsconfig.json ├── PRIVACY.md ├── package.json ├── eslint.config.js ├── DEVELOP.md ├── README.md ├── angular.json └── LICENSE /src/app/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app.component'; 2 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard" 3 | } 4 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/img/pinboard_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/src/img/pinboard_128.png -------------------------------------------------------------------------------- /src/img/pinboard_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/src/img/pinboard_16.png -------------------------------------------------------------------------------- /src/img/pinboard_24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/src/img/pinboard_24.png -------------------------------------------------------------------------------- /src/img/pinboard_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/src/img/pinboard_256.png -------------------------------------------------------------------------------- /src/img/pinboard_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/src/img/pinboard_32.png -------------------------------------------------------------------------------- /src/img/pinboard_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/src/img/pinboard_48.png -------------------------------------------------------------------------------- /src/img/pinboard_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/src/img/pinboard_64.png -------------------------------------------------------------------------------- /src/img/pinboard_96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/src/img/pinboard_96.png -------------------------------------------------------------------------------- /src/img/pinboard_idle_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/src/img/pinboard_idle_128.png -------------------------------------------------------------------------------- /src/img/pinboard_idle_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/src/img/pinboard_idle_16.png -------------------------------------------------------------------------------- /src/img/pinboard_idle_24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/src/img/pinboard_idle_24.png -------------------------------------------------------------------------------- /src/img/pinboard_idle_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/src/img/pinboard_idle_256.png -------------------------------------------------------------------------------- /src/img/pinboard_idle_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/src/img/pinboard_idle_32.png -------------------------------------------------------------------------------- /src/img/pinboard_idle_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/src/img/pinboard_idle_48.png -------------------------------------------------------------------------------- /src/img/pinboard_idle_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/src/img/pinboard_idle_64.png -------------------------------------------------------------------------------- /src/img/pinboard_idle_96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/src/img/pinboard_idle_96.png -------------------------------------------------------------------------------- /screenshots/pinboard-pin-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/screenshots/pinboard-pin-dark.png -------------------------------------------------------------------------------- /screenshots/pinboard-pin-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/screenshots/pinboard-pin-light.png -------------------------------------------------------------------------------- /screenshots/pinboard-pin-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cito/Pinboard-Pin/HEAD/screenshots/pinboard-pin-settings.png -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "files": [ 4 | "src/main.ts", 5 | "src/polyfills.ts" 6 | ], 7 | "include": [ 8 | "src/typings.d.ts" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | // This file includes polyfills needed by Angular and is loaded before 2 | // the app. You can add your own extra polyfills to this file. 3 | 4 | // Zoneless: Zone JS is not required when using provideZonelessChangeDetection. 5 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |You must log in to Pinboard first using the API token 6 | that you can find on your 7 | Pinboard password page.
8 | 9 | 34 | 35 |{{error}}
151 | } 152 | 153 | @if (error) { 154 | 163 | } 164 | 165 |" + 202 | this.description.slice(0, 3200 - 25) + 203 | ""; 204 | } 205 | this.keywords = content.keywords; 206 | this.tags = null; 207 | this.unshared = this.options.unshared; 208 | this.toread = this.options.toread; 209 | this.retry = true; 210 | this.suggested = this.popular = null; 211 | // query both page data and suggested tags 212 | this.pinboard.getAndSuggest(this.url).subscribe({ 213 | next: (data: unknown) => 214 | this.setData( 215 | data as { 216 | posts?: Array<{ 217 | href: string; 218 | description: string; 219 | extended: string; 220 | tags: string; 221 | shared: string; 222 | toread: string; 223 | }>; 224 | } & { popular?: string[]; recommended?: string[] } & { 225 | date?: string; 226 | } 227 | ), 228 | error: (error: unknown) => 229 | this.logError( 230 | "Cannot check this page on Pinboard.", 231 | errorMessage(error) 232 | ), 233 | }); 234 | } else { 235 | this.logError( 236 | "Can only pin normal web pages.", 237 | "Cannot get the URL of the page" 238 | ); 239 | } 240 | } 241 | 242 | // receive page data and suggested tags from parallel queries 243 | setData( 244 | data: { 245 | posts?: Array<{ 246 | href: string; 247 | description: string; 248 | extended: string; 249 | tags: string; 250 | shared: string; 251 | toread: string; 252 | }>; 253 | } & { popular?: string[]; recommended?: string[] } & { date?: string } 254 | ): void { 255 | if (data.posts && data.posts.length) { 256 | this.date = data?.date; 257 | const post = data.posts[0]; 258 | this.url = post.href; 259 | this.title = post.description; 260 | this.description = post.extended; 261 | this.tags = post.tags; 262 | this.unshared = post.shared !== "yes"; 263 | this.toread = post.toread === "yes"; 264 | this.update = true; 265 | // set browser icon to saved state 266 | void browser.tabs.query({ url: this.url }).then( 267 | (tabs: browser.tabs.Tab[]) => { 268 | for (const tab of tabs) { 269 | this.icon.setIcon(tab.id, true); 270 | } 271 | }, 272 | (error: unknown) => logError(error) 273 | ); 274 | } 275 | // Note: "popular" and "recommended" are interchanged in Pinboard 276 | if (data.popular) { 277 | this.suggested = data.popular; 278 | } 279 | if (this.options.popular && data.recommended) { 280 | this.popular = data.recommended; 281 | } 282 | this.pinboard 283 | .cachedTags() 284 | .pipe(finalize(() => this.cdr.detectChanges())) 285 | .subscribe({ 286 | next: (tags) => { 287 | this.allTags = tags; 288 | if (this.tags) { 289 | this.tags = this.tags.trim(); 290 | this.savedTags = this.tags; 291 | this.tags += " "; 292 | } else { 293 | this.savedTags = null; 294 | } 295 | this.completions = null; 296 | this.setReady(); 297 | }, 298 | }); 299 | } 300 | 301 | // set form as ready for input 302 | setReady(): void { 303 | this.ready = true; 304 | this.cdr.detectChanges(); 305 | // wait until inputs have been enabled, then focus 306 | timer(0).subscribe(() => { 307 | const focus = this.url 308 | ? this.title 309 | ? this.tags && !this.description 310 | ? "description" 311 | : "tags" 312 | : "title" 313 | : "url"; 314 | const element = (this.eref.nativeElement as HTMLElement).querySelector( 315 | "#" + focus 316 | ); 317 | if (element instanceof HTMLElement) { 318 | element.focus(); 319 | } 320 | }); 321 | } 322 | 323 | // check whether the given tags have already been added 324 | hasTags(tags: string | string[]): boolean { 325 | if (!this.tags) { 326 | return false; 327 | } 328 | if (!Array.isArray(tags)) { 329 | tags = [tags]; 330 | } 331 | const allTags = this.tags.split(" ").filter((tag) => !!tag); 332 | return tags.every((tag) => allTags.includes(tag)); 333 | } 334 | 335 | // add the given tags if they have not already been added, otherwise remove 336 | addTags(tags: string | string[]): void { 337 | if (!Array.isArray(tags)) { 338 | tags = [tags]; 339 | } 340 | let allTags = (this.tags || "").split(" ").filter((tag) => !!tag); 341 | const newTags = tags.filter((tag) => !allTags.includes(tag)); 342 | if (newTags.length) { 343 | // some tags are new 344 | allTags.push(...newTags); // add these tags 345 | } else { 346 | // all tags have already been added, remove these tags again 347 | allTags = allTags.filter((tag) => !tags.includes(tag)); 348 | } 349 | this.tags = allTags.join(" "); 350 | } 351 | 352 | // this method is called when keys have been pressed down in the tabs field 353 | tagsKeyDown(event: KeyboardEvent): boolean { 354 | if (!this.ready || !this.tagsFocus || !this.completions) { 355 | return true; 356 | } 357 | // Firefox reacts to some of our control keys as well, so to prevent this 358 | // from happening, we have to listen here before the key has been pressed 359 | let control = true; 360 | switch (event.code) { 361 | case "Home": 362 | this.tagSelected = 0; 363 | break; 364 | case "End": 365 | this.tagSelected = this.completions.length - 1; 366 | break; 367 | case "ArrowDown": 368 | if (this.tagSelected < this.completions.length - 1) { 369 | ++this.tagSelected; 370 | } 371 | break; 372 | case "ArrowUp": 373 | if (this.tagSelected > 0) { 374 | --this.tagSelected; 375 | } 376 | break; 377 | case "Enter": 378 | case "Tab": 379 | case "ArrowRight": 380 | const tag = this.completions[this.tagSelected]; 381 | const inputElement = event.target as HTMLInputElement; 382 | let value = inputElement.value; 383 | const words = value.split(" "); 384 | if (words.length) { 385 | words.pop(); 386 | } 387 | if (!words.includes(tag)) { 388 | words.push(tag); 389 | value = words.join(" ") + " "; 390 | this.tagsChanged(value); 391 | this.tags = value; 392 | } 393 | break; 394 | default: 395 | control = false; 396 | } 397 | return !control; 398 | } 399 | 400 | // this method is called when keys have been released in the tabs field 401 | tagsKeyUp(event: KeyboardEvent): boolean { 402 | // the field value is changed after the key has been pressed, 403 | // so this is the right moment for checking for value changes 404 | if (this.ready && this.tagsFocus) { 405 | this.tagsSubject.next((event.target as HTMLInputElement).value); 406 | } 407 | return true; 408 | } 409 | 410 | // this method is called with debounce when tags have changed 411 | // it must then determine the list of tag completions 412 | tagsChanged(tags: string): void { 413 | const words = tags.replace(",", " ").split(" "); 414 | let word = words.length ? words.pop() : null; 415 | const allTags = this.allTags; 416 | const matches: [string, number][] = []; 417 | const alpha = this.options.alpha; 418 | if (word) { 419 | word = word.toLowerCase(); 420 | for (const tag of Object.keys(allTags)) { 421 | if (tag.toLowerCase().startsWith(word) && !words.includes(tag)) { 422 | matches.push([tag, alpha ? 0 : allTags[tag]]); 423 | } 424 | } 425 | } 426 | // sort matching tags by decreasing frequency 427 | matches.sort( 428 | (a: [string, number], b: [string, number]) => 429 | b[1] - a[1] || a[0].localeCompare(b[0]) 430 | ); 431 | matches.splice(maxCompletions); 432 | const completions: string[] = matches.map((a) => a[0]).reverse(); 433 | if (completions.length) { 434 | const oldCompletions = this.completions; 435 | if ( 436 | !oldCompletions || 437 | completions.length !== oldCompletions.length || 438 | completions.some((tag, i) => completions[i] !== oldCompletions[i]) 439 | ) { 440 | this.completions = completions; 441 | this.tagSelected = completions.length - 1; 442 | } 443 | } else { 444 | this.completions = null; 445 | } 446 | } 447 | 448 | // this method is called when a tag completion was clicked 449 | selectCompletion(tag: string): boolean { 450 | let value = this.tags || ""; 451 | const words = value.split(" "); 452 | if (words.length) { 453 | words.pop(); 454 | } 455 | if (!words.includes(tag)) { 456 | words.push(tag); 457 | value = words.join(" ") + " "; 458 | this.tagsChanged(value); 459 | this.tags = value; 460 | } 461 | return false; 462 | } 463 | 464 | // delete the current bookmark 465 | remove(): boolean { 466 | if (this.ready && this.update && this.url) { 467 | this.pinboard.delete(this.url).subscribe({ 468 | next: () => { 469 | // update the tags in the cache 470 | const savedTags = this.savedTags ? this.savedTags.split(" ") : []; 471 | this.pinboard 472 | .updateTagCache([], savedTags) 473 | .pipe( 474 | finalize( 475 | // set the browser icon to unsaved state 476 | () => 477 | void browser.tabs.query({ url: this.url }).then( 478 | (tabs: browser.tabs.Tab[]) => { 479 | for (const tab of tabs) { 480 | this.icon.setIcon(tab.id, false); 481 | } 482 | this.cancel(); 483 | }, 484 | (error: unknown) => { 485 | logError(error); 486 | this.cancel(); 487 | } 488 | ) 489 | ) 490 | ) 491 | .subscribe(); 492 | }, 493 | error: (error: unknown) => { 494 | this.logError( 495 | "Sorry, could not remove this page from Pinboard", 496 | errorMessage(error) 497 | ); 498 | }, 499 | }); 500 | } 501 | return false; 502 | } 503 | 504 | // reset error message 505 | reset(): boolean { 506 | this.error = null; 507 | return false; 508 | } 509 | 510 | // submit form 511 | submit(form: NgForm): boolean { 512 | if (form.valid) { 513 | this.save(form.value as Post); 514 | } 515 | return false; 516 | } 517 | 518 | // save page to Pinboard 519 | save(value: Post): void { 520 | value.url = (value.url || "").trim(); 521 | value.title = (value.title || "").trim(); 522 | if (!value.url || !value.title) { 523 | return; 524 | } 525 | value.description = (value.description || "").trim() || null; 526 | // clean up tags, maximum of 100 tags with 255 chars each 527 | const tags = value.tags 528 | ? value.tags 529 | .split(" ") 530 | .filter((tag) => !!tag) 531 | .slice(0, 100) 532 | .map((tag) => tag.slice(0, 255)) 533 | : []; 534 | value.tags = tags.join(" "); 535 | const savedTags = this.savedTags ? this.savedTags.split(" ") : []; 536 | this.pinboard.save(value).subscribe({ 537 | next: (error: unknown) => { 538 | if (error) { 539 | this.logError( 540 | "Sorry, could not save this page to Pinboard", 541 | errorMessage(error) 542 | ); 543 | } else { 544 | this.pinboard 545 | .updateTagCache(tags, savedTags) 546 | .pipe( 547 | finalize( 548 | () => 549 | void browser.tabs.query({ url: this.url }).then( 550 | (tabs: browser.tabs.Tab[]) => { 551 | for (const tab of tabs) { 552 | this.icon.setIcon(tab.id, true); 553 | } 554 | this.cancel(); 555 | }, 556 | (error: unknown) => { 557 | logError(error); 558 | this.cancel(); 559 | } 560 | ) 561 | ) 562 | ) 563 | .subscribe(); 564 | } 565 | }, 566 | error: (error: unknown) => { 567 | this.logError( 568 | "Sorry, could not save this page to Pinboard", 569 | errorMessage(error) 570 | ); 571 | }, 572 | }); 573 | } 574 | 575 | // save current tabs as tab set to Pinboard 576 | saveTabs(): void { 577 | void browser.tabs 578 | .query({ windowType: "normal", url: "*://*/*" }) 579 | .then((tabs: browser.tabs.Tab[]) => { 580 | const wTabs: Record< 581 | number, 582 | Record