<commit_before>// this component is the save bookmark dialog displayed in the popup

import { Component, ElementRef, OnInit, OnDestroy } from '@angular/core';
import {NgForm} from '@angular/forms';
import {Router} from '@angular/router';

import {Subscription, Subject, timer} from 'rxjs';
import {debounceTime, distinctUntilChanged, finalize} from 'rxjs/operators';

import {IconService} from '../icon.service';
import {PinboardService, pinboardPage} from '../pinboard.service';
import {Options, StorageService} from '../storage.service';

const debounceDueTime = 250; // timeout in ms for reacting to changes
const maxCompletions = 9; // maximum number of suggested completions

export interface Post {
  url: string;
  title: string;
  description: string;
  tags: string;
  unshared: boolean;
  toread: boolean;
}

interface Content {
  url: string;
  title: string;
  description: string;
  keywords: string[];
}

interface RawContent {
  url: string;
  title: string;
  selection: string;
  description: string;
  keywords: string;
}


// Pin page form
// TODO: This class is too big, should be refactored.
// At least the tag handling should go into sub component(s).

@Component({
  selector: 'app-popup',
  templateUrl: './pinpage.component.html',
  styleUrls: ['./pinpage.component.css']
})
export class PinPageComponent implements OnInit, OnDestroy {

  url: string;
  title: string;
  description: string; // description
  tags: string; // current tags
  savedTags: string; // tags already saved for this URL
  allTags: {[tag: string]: number}; // all of our tags with frequency
  suggested: string[]; // recommended tags from our own
  popular: string[]; // other popular tags
  keywords: string[]; // keywords taken from the page
  completions: string[]; // tag completions
  tagsFocus: boolean; // whether the tags field has focus
  tagSelected: number; // index of the selected tag
  unshared: boolean;
  toread: boolean;
  ready: boolean;
  update: boolean;
  date: string;
  error: boolean;
  retry: boolean;
  options: Options;

  private tagsSubject = new Subject();
  private tagsSubscription: Subscription;

  constructor(private pinboard: PinboardService,
              private storage: StorageService,
              private icon: IconService,
              private router: Router,
              private eref: ElementRef) { }

  ngOnInit() {
    this.ready = this.update = this.error = this.retry = false;
    this.storage.getOptions().subscribe(options => {
      this.options = options;
      const getContent = options.meta || options.selection ?
        browser.tabs.executeScript(null, {file: '/js/content.js'}
          ).then((content: Array<RawContent>) =>
          this.processContent(content[0])).catch(() =>
          this.getContent()) : this.getContent();
      getContent.then(
        content => this.setContent(content),
        error => this.logError(
          'Can only pin normal web pages.', error.toString()));
    });
    this.tagsFocus = false;
    this.tagsSubscription = this.tagsSubject.pipe(
      debounceTime(debounceDueTime), distinctUntilChanged()
    ).subscribe(value => this.tagsChanged(value));
  }

  ngOnDestroy() {
    this.tagsSubscription.unsubscribe();
  }

  // process the data gathered by the content script
  processContent(content: RawContent): Content {
    let { url, title } = content;
    url = url || null;
    title = title ? (title.length > 255 ?  // trim title
      title.slice(0, 254) + '\u2026' : title) : null;
    const options = this.options;
    let description = options.selection ? content.selection : null;
    if (!description && options.meta) {
      description = content.description;
    }
    description = description ? description.slice(0, 64000) : null;
    let keywords: string[] = [];
    if (options.meta && content.keywords) {
      for (let word of content.keywords.split(',')) {
        word = word.replace(/\s+/, '').slice(
          0, 255).toLowerCase();
        if (word && !keywords.includes(word)) {
          keywords.push(word);
          if (keywords.length >= 100) {
            break;
          }
        }
      }
    }
    keywords = keywords.length ? keywords.slice(0, 6400) : null;
    return {url, title, description, keywords};
  }

  // get url and title of content (used if content script cannot run)
  getContent(): Promise<Content> {
    return browser.tabs.query(
      {active: true, currentWindow: true}).then(
      tabs => tabs[0]).then(tab => ({
        url: tab.url, title: tab.title,
        description: null, keywords: null
    }));
  }

  // store info on current content in the form inputs
  setContent(content: Content): void {
    if (content && content.url && this.pinboard.isValidUrl(content.url)) {
      this.url = content.url;
      this.title = content.title;
      this.description = content.description;
      if (this.description && this.options.blockquote) {
        this.description = '<blockquote>' +
          this.description.slice(0, 64000 - 25) + '</blockquote>';
      }
      this.keywords = content.keywords;
      this.tags = null;
      this.unshared = this.options.unshared;
      this.toread = this.options.toread;
      this.retry = true;
      this.suggested = this.popular = null;
      // query both page data and suggested tags
      this.pinboard.getAndSuggest(this.url).subscribe(
          data => this.setData(data),
          error => this.logError(
            'Cannot check this page on Pinboard.', error.toString()));
    } else {
      this.logError('Can only pin normal web pages.',
        'Cannot get the URL of the page');
    }
  }

  // receive page data and suggested tags from parallel queries
  setData(data: any): void {
    if (data.posts && data.posts.length) {
      this.date = data ? data.date : null;
      const post = data.posts[0];
      this.url = post.href;
      this.title = post.description;
      this.description = post.extended;
      this.tags = post.tags;
      this.unshared = post.shared !== 'yes';
      this.toread = post.toread === 'yes';
      this.update = true;
      // set browser icon to saved state
      browser.tabs.query({url: this.url}).then(tabs => {
          for (const tab of tabs) {
            this.icon.setIcon(tab.id, true);
          }
        },
        error => console.error(error.toString()));
    }
    // Note: "popular" and "recommended" are interchanged in Pinboard
    if (data.popular) {
      this.suggested = data.popular;
    }
    if (data.recommended) {
      this.popular = data.recommended;
    }
    this.pinboard.cachedTags().subscribe(tags => {
      this.allTags = tags;
      if (this.tags) {
        this.tags.trim();
        this.savedTags = this.tags;
        this.tags += ' ';
      } else {
        this.savedTags = null;
      }
      this.completions = null;
      this.setReady();
    });
  }

  // set form as ready for input
  setReady(): void {
    this.ready = true;
    // wait until inputs have been enabled, then focus
    timer(0).subscribe(() => {
      const focus = this.url ? (this.title ? (
        this.tags && ! this.description ?
        'description' : 'tags') : 'title') : 'url';
      this.eref.nativeElement.querySelector('#' + focus).focus();
    });
  }

  // check whether the given tags have already been added
  hasTags(tags: string|string[]): boolean {
    if (!this.tags) {
      return false;
    }
    if (!Array.isArray(tags)) {
      tags = [tags as string];
    }
    const allTags = this.tags.split(' ').filter(tag => !!tag);
    return (tags as string[]).every(tag => allTags.includes(tag));
  }

  // add the given tags if they have not already been added, otherwise remove
  addTags(tags: string|string[]): void {
    if (!Array.isArray(tags)) {
      tags = [tags as string];
    }
    let allTags = (this.tags || '').split(' ').filter(tag => !!tag);
    const newTags = (tags as string[]).filter(tag => !allTags.includes(tag));
    if (newTags.length) { // some tags are new
      allTags.push(...newTags);  // add these tags
    } else { // all tags have already been added, remove these tags again
      allTags = allTags.filter(tag => !(tags as string[]).includes(tag));
    }
    this.tags = allTags.join(' ');
  }

  // this method is called when keys have been pressed down in the tabs field
  tagsKeyDown(event: any): boolean {
    if (!this.ready || !this.tagsFocus || !this.completions) {
      return true;
    }
    // Firefox reacts to some of our control keys as well, so to prevent this
    // from happening, we have to listen here before the key has been pressed
    let control = true;
    switch (event.code) {
      case 'Home':
        this.tagSelected = 0;
        break;
      case 'End':
        this.tagSelected = this.completions.length - 1;
        break;
      case 'ArrowDown':
        if (this.tagSelected < this.completions.length - 1) {
          ++this.tagSelected;
        }
        break;
      case 'ArrowUp':
        if (this.tagSelected > 0) {
          --this.tagSelected;
        }
        break;
      case 'Enter':
      case 'Tab':
      case 'ArrowRight':
        const tag = this.completions[this.tagSelected];
        let value = event.target.value;
        const words = value.split(' ');
        if (words.length) {
          words.pop();
        }
        if (!words.includes(tag)) {
          words.push(tag);
          value = words.join(' ') + ' ';
          this.tagsChanged(value);
          this.tags = value;
        }
        break;
      default:
        control = false;
    }
    return !control;
  }

  // this method is called when keys have been released in the tabs field
  tagsKeyUp(event: any): boolean {
    // the field value is changed after the key has been pressed,
    // so this is the right moment for checking for value changes
    if (this.ready && this.tagsFocus) {
      this.tagsSubject.next(event.target.value);
    }
    return true;
  }

  // this method is called with debounce when tags have changed
  // it must then determine the list of tag completions
  tagsChanged(tags): void {
    const words = tags.replace(',', ' ').split(' ');
    let word = words.length ? words.pop() : null;
    const allTags = this.allTags;
    const matches: [string, number][] = [];
    const alpha = this.options.alpha;
    if (word) {
      word = word.toLowerCase();
      for (const tag in allTags) {
        if (allTags.hasOwnProperty(tag)) {
          if (tag.toLowerCase().startsWith(word) && !words.includes(tag)) {
            matches.push([tag, alpha ? 0 : allTags[tag]]);
          }
        }
      }
    }
    // sort matching tags by decreasing frequency
    matches.sort((a: [string, number], b: [string, number]) =>
        b[1] - a[1] || a[0].localeCompare(b[0]));
    matches.splice(maxCompletions);
    const completions: string[] = matches.map(a => a[0]).reverse();
    if (completions.length) {
      const oldCompletions = this.completions;
      if (!oldCompletions || completions.length !== oldCompletions.length ||
        completions.some((tag, i) =>
          completions[i] !== oldCompletions[i])) {
        this.completions = completions;
        this.tagSelected = completions.length - 1;
      }
    } else {
      this.completions = null;
    }
  }

  // this method is called when a tag completion was clicked
  selectCompletion(tag: string): boolean {
    let value = this.tags || '';
    const words = value.split(' ');
    if (words.length) {
      words.pop();
    }
    if (!words.includes(tag)) {
      words.push(tag);
      value = words.join(' ') + ' ';
      this.tagsChanged(value);
      this.tags = value;
    }
    return false;
  }

  // delete the current bookmark
  remove(): boolean {
    if (this.ready && this.update && this.url) {
      this.pinboard.delete(this.url).subscribe(
        () => {
          // update the tags in the cache
          const savedTags = this.savedTags ? this.savedTags.split(' ') : [];
          this.pinboard.updateTagCache([], savedTags).pipe(finalize(
            // set the browser icon to unsaved state
            () => browser.tabs.query({url: this.url}).then(tabs => {
              for (const tab of tabs) {
                this.icon.setIcon(tab.id, false);
              }
              this.cancel();
            }, error => {
              console.error(error.toString());
              this.cancel();
            }))).subscribe();
        },
        error => {
          this.logError('Sorry, could not remove this page from Pinboard',
            error.toString());
        });
    }
    return false;
  }

  // reset error message
  reset(): boolean {
    this.error = null;
    return false;
  }

  // submit form
  submit(form: NgForm): boolean {
    if (form.valid) {
      this.save(form.value);
    }
    return false;
  }

  // save page to Pinboard
  save(value: Post): void {
    value.url = (value.url || '').trim();
    value.title = (value.title || '').trim();
    if (!value.url || !value.title) {
      return;
    }
    value.description = (value.description || '').trim() || null;
    // clean up tags, maximum of 100 tags with 255 chars each
    const tags = value.tags ? value.tags.split(' ').filter(
      tag => !!tag).slice(0, 100).map(tag => tag.slice(0, 255)) : [];
    value.tags = tags.join(' ');
    const savedTags = this.savedTags ? this.savedTags.split(' ') : [];
    this.pinboard.save(value).subscribe(
      error => {
        if (error) {
          this.logError('Sorry, could not save this page to Pinboard',
            error);
        } else {
          // update the tags in the cache
          this.pinboard.updateTagCache(tags, savedTags).pipe(finalize(
            // set the browser icon to saved state
            () => browser.tabs.query({url: this.url}).then(tabs => {
              for (const tab of tabs) {
                this.icon.setIcon(tab.id, true);
              }
              this.cancel();
            }, error => {
              console.error(error.toString());
              this.cancel();
            }))).subscribe();
        }
      },
      error => {
        this.logError('Sorry, could not save this page to Pinboard',
          error.toString());
      });
  }

  // save current tabs as tab set to Pinboard
  saveTabs(): void {
    browser.tabs.query({windowType: 'normal', url: '*://*/*'}).then(
      tabs => {
        const wTabs = {};
        for (const tab of tabs) {
          const wId = tab.windowId;
          if (!wTabs[wId]) {
            wTabs[wId] = {};
          }
          wTabs[wId][tab.index] = {title: tab.title, url: tab.url};
        }
        const windows = Object.keys(wTabs).map(
          wId => Object.keys(wTabs[wId]).map(
            index => wTabs[wId][index]));
        if (windows.length) {
          const data = {
            browser: 'ffox', windows: windows,
          };
          this.pinboard.saveTabs(data).subscribe(() => {
            this.cancel();
          }, error => {
            this.logError(
              'Sorry, could not save this tab set to Pinboard.',
              error.toString());
          });
        }
      });
  }

  // navigate to options
  settings(): void {
    // store a note that we are showing on a popup page
    this.storage.setInfo('options.page', 'popup');
    this.router.navigate(['/options']);
  }

  // log out from Pinboard
  logOut(): void {
    this.pinboard.forgetToken().subscribe(
      () => this.router.navigate(['/login']));
  }

  // show error message and log it on the console
  logError(errmsg, logmsg): void {
    if (errmsg) {
      console.error(logmsg || errmsg);
    }
    this.error = errmsg;
  }

  pinboardLink(page): boolean {
    if (page && page.indexOf('~') >= 0) {
      this.pinboard.userName.subscribe(
        name => this.pinboardLink(page.replace('~', 'u:' + name)));
    } else {
      let url = pinboardPage;
      if (page) {
        url += page;
      }
      browser.tabs.create({url: url});
      this.cancel();
    }
    return false;
  }

  // close the whole popup
  cancel(): void {
    window.close();
  }

}
<commit_msg>TypeScript now supports optional chaining<commit_after>// this component is the save bookmark dialog displayed in the popup

import { Component, ElementRef, OnInit, OnDestroy } from '@angular/core';
import {NgForm} from '@angular/forms';
import {Router} from '@angular/router';

import {Subscription, Subject, timer} from 'rxjs';
import {debounceTime, distinctUntilChanged, finalize} from 'rxjs/operators';

import {IconService} from '../icon.service';
import {PinboardService, pinboardPage} from '../pinboard.service';
import {Options, StorageService} from '../storage.service';

const debounceDueTime = 250; // timeout in ms for reacting to changes
const maxCompletions = 9; // maximum number of suggested completions

export interface Post {
  url: string;
  title: string;
  description: string;
  tags: string;
  unshared: boolean;
  toread: boolean;
}

interface Content {
  url: string;
  title: string;
  description: string;
  keywords: string[];
}

interface RawContent {
  url: string;
  title: string;
  selection: string;
  description: string;
  keywords: string;
}


// Pin page form
// TODO: This class is too big, should be refactored.
// At least the tag handling should go into sub component(s).

@Component({
  selector: 'app-popup',
  templateUrl: './pinpage.component.html',
  styleUrls: ['./pinpage.component.css']
})
export class PinPageComponent implements OnInit, OnDestroy {

  url: string;
  title: string;
  description: string; // description
  tags: string; // current tags
  savedTags: string; // tags already saved for this URL
  allTags: {[tag: string]: number}; // all of our tags with frequency
  suggested: string[]; // recommended tags from our own
  popular: string[]; // other popular tags
  keywords: string[]; // keywords taken from the page
  completions: string[]; // tag completions
  tagsFocus: boolean; // whether the tags field has focus
  tagSelected: number; // index of the selected tag
  unshared: boolean;
  toread: boolean;
  ready: boolean;
  update: boolean;
  date: string;
  error: boolean;
  retry: boolean;
  options: Options;

  private tagsSubject = new Subject();
  private tagsSubscription: Subscription;

  constructor(private pinboard: PinboardService,
              private storage: StorageService,
              private icon: IconService,
              private router: Router,
              private eref: ElementRef) { }

  ngOnInit() {
    this.ready = this.update = this.error = this.retry = false;
    this.storage.getOptions().subscribe(options => {
      this.options = options;
      const getContent = options.meta || options.selection ?
        browser.tabs.executeScript(null, {file: '/js/content.js'}
          ).then((content: Array<RawContent>) =>
          this.processContent(content[0])).catch(() =>
          this.getContent()) : this.getContent();
      getContent.then(
        content => this.setContent(content),
        error => this.logError(
          'Can only pin normal web pages.', error.toString()));
    });
    this.tagsFocus = false;
    this.tagsSubscription = this.tagsSubject.pipe(
      debounceTime(debounceDueTime), distinctUntilChanged()
    ).subscribe(value => this.tagsChanged(value));
  }

  ngOnDestroy() {
    this.tagsSubscription.unsubscribe();
  }

  // process the data gathered by the content script
  processContent(content: RawContent): Content {
    let { url, title } = content;
    url = url || null;
    title = title ? (title.length > 255 ?  // trim title
      title.slice(0, 254) + '\u2026' : title) : null;
    const options = this.options;
    let description = options.selection ? content.selection : null;
    if (!description && options.meta) {
      description = content.description;
    }
    description = description?.slice(0, 64000);
    let keywords: string[] = [];
    if (options.meta && content.keywords) {
      for (let word of content.keywords.split(',')) {
        word = word.replace(/\s+/, '').slice(
          0, 255).toLowerCase();
        if (word && !keywords.includes(word)) {
          keywords.push(word);
          if (keywords.length >= 100) {
            break;
          }
        }
      }
    }
    keywords = keywords.length ? keywords.slice(0, 6400) : null;
    return {url, title, description, keywords};
  }

  // get url and title of content (used if content script cannot run)
  getContent(): Promise<Content> {
    return browser.tabs.query(
      {active: true, currentWindow: true}).then(
      tabs => tabs[0]).then(tab => ({
        url: tab.url, title: tab.title,
        description: null, keywords: null
    }));
  }

  // store info on current content in the form inputs
  setContent(content: Content): void {
    if (content && content.url && this.pinboard.isValidUrl(content.url)) {
      this.url = content.url;
      this.title = content.title;
      this.description = content.description;
      if (this.description && this.options.blockquote) {
        this.description = '<blockquote>' +
          this.description.slice(0, 64000 - 25) + '</blockquote>';
      }
      this.keywords = content.keywords;
      this.tags = null;
      this.unshared = this.options.unshared;
      this.toread = this.options.toread;
      this.retry = true;
      this.suggested = this.popular = null;
      // query both page data and suggested tags
      this.pinboard.getAndSuggest(this.url).subscribe(
          data => this.setData(data),
          error => this.logError(
            'Cannot check this page on Pinboard.', error.toString()));
    } else {
      this.logError('Can only pin normal web pages.',
        'Cannot get the URL of the page');
    }
  }

  // receive page data and suggested tags from parallel queries
  setData(data: any): void {
    if (data.posts && data.posts.length) {
      this.date = data?.date;
      const post = data.posts[0];
      this.url = post.href;
      this.title = post.description;
      this.description = post.extended;
      this.tags = post.tags;
      this.unshared = post.shared !== 'yes';
      this.toread = post.toread === 'yes';
      this.update = true;
      // set browser icon to saved state
      browser.tabs.query({url: this.url}).then(tabs => {
          for (const tab of tabs) {
            this.icon.setIcon(tab.id, true);
          }
        },
        error => console.error(error.toString()));
    }
    // Note: "popular" and "recommended" are interchanged in Pinboard
    if (data.popular) {
      this.suggested = data.popular;
    }
    if (data.recommended) {
      this.popular = data.recommended;
    }
    this.pinboard.cachedTags().subscribe(tags => {
      this.allTags = tags;
      if (this.tags) {
        this.tags.trim();
        this.savedTags = this.tags;
        this.tags += ' ';
      } else {
        this.savedTags = null;
      }
      this.completions = null;
      this.setReady();
    });
  }

  // set form as ready for input
  setReady(): void {
    this.ready = true;
    // wait until inputs have been enabled, then focus
    timer(0).subscribe(() => {
      const focus = this.url ? (this.title ? (
        this.tags && ! this.description ?
        'description' : 'tags') : 'title') : 'url';
      this.eref.nativeElement.querySelector('#' + focus).focus();
    });
  }

  // check whether the given tags have already been added
  hasTags(tags: string|string[]): boolean {
    if (!this.tags) {
      return false;
    }
    if (!Array.isArray(tags)) {
      tags = [tags as string];
    }
    const allTags = this.tags.split(' ').filter(tag => !!tag);
    return (tags as string[]).every(tag => allTags.includes(tag));
  }

  // add the given tags if they have not already been added, otherwise remove
  addTags(tags: string|string[]): void {
    if (!Array.isArray(tags)) {
      tags = [tags as string];
    }
    let allTags = (this.tags || '').split(' ').filter(tag => !!tag);
    const newTags = (tags as string[]).filter(tag => !allTags.includes(tag));
    if (newTags.length) { // some tags are new
      allTags.push(...newTags);  // add these tags
    } else { // all tags have already been added, remove these tags again
      allTags = allTags.filter(tag => !(tags as string[]).includes(tag));
    }
    this.tags = allTags.join(' ');
  }

  // this method is called when keys have been pressed down in the tabs field
  tagsKeyDown(event: any): boolean {
    if (!this.ready || !this.tagsFocus || !this.completions) {
      return true;
    }
    // Firefox reacts to some of our control keys as well, so to prevent this
    // from happening, we have to listen here before the key has been pressed
    let control = true;
    switch (event.code) {
      case 'Home':
        this.tagSelected = 0;
        break;
      case 'End':
        this.tagSelected = this.completions.length - 1;
        break;
      case 'ArrowDown':
        if (this.tagSelected < this.completions.length - 1) {
          ++this.tagSelected;
        }
        break;
      case 'ArrowUp':
        if (this.tagSelected > 0) {
          --this.tagSelected;
        }
        break;
      case 'Enter':
      case 'Tab':
      case 'ArrowRight':
        const tag = this.completions[this.tagSelected];
        let value = event.target.value;
        const words = value.split(' ');
        if (words.length) {
          words.pop();
        }
        if (!words.includes(tag)) {
          words.push(tag);
          value = words.join(' ') + ' ';
          this.tagsChanged(value);
          this.tags = value;
        }
        break;
      default:
        control = false;
    }
    return !control;
  }

  // this method is called when keys have been released in the tabs field
  tagsKeyUp(event: any): boolean {
    // the field value is changed after the key has been pressed,
    // so this is the right moment for checking for value changes
    if (this.ready && this.tagsFocus) {
      this.tagsSubject.next(event.target.value);
    }
    return true;
  }

  // this method is called with debounce when tags have changed
  // it must then determine the list of tag completions
  tagsChanged(tags): void {
    const words = tags.replace(',', ' ').split(' ');
    let word = words.length ? words.pop() : null;
    const allTags = this.allTags;
    const matches: [string, number][] = [];
    const alpha = this.options.alpha;
    if (word) {
      word = word.toLowerCase();
      for (const tag in allTags) {
        if (allTags.hasOwnProperty(tag)) {
          if (tag.toLowerCase().startsWith(word) && !words.includes(tag)) {
            matches.push([tag, alpha ? 0 : allTags[tag]]);
          }
        }
      }
    }
    // sort matching tags by decreasing frequency
    matches.sort((a: [string, number], b: [string, number]) =>
        b[1] - a[1] || a[0].localeCompare(b[0]));
    matches.splice(maxCompletions);
    const completions: string[] = matches.map(a => a[0]).reverse();
    if (completions.length) {
      const oldCompletions = this.completions;
      if (!oldCompletions || completions.length !== oldCompletions.length ||
        completions.some((tag, i) =>
          completions[i] !== oldCompletions[i])) {
        this.completions = completions;
        this.tagSelected = completions.length - 1;
      }
    } else {
      this.completions = null;
    }
  }

  // this method is called when a tag completion was clicked
  selectCompletion(tag: string): boolean {
    let value = this.tags || '';
    const words = value.split(' ');
    if (words.length) {
      words.pop();
    }
    if (!words.includes(tag)) {
      words.push(tag);
      value = words.join(' ') + ' ';
      this.tagsChanged(value);
      this.tags = value;
    }
    return false;
  }

  // delete the current bookmark
  remove(): boolean {
    if (this.ready && this.update && this.url) {
      this.pinboard.delete(this.url).subscribe(
        () => {
          // update the tags in the cache
          const savedTags = this.savedTags ? this.savedTags.split(' ') : [];
          this.pinboard.updateTagCache([], savedTags).pipe(finalize(
            // set the browser icon to unsaved state
            () => browser.tabs.query({url: this.url}).then(tabs => {
              for (const tab of tabs) {
                this.icon.setIcon(tab.id, false);
              }
              this.cancel();
            }, error => {
              console.error(error.toString());
              this.cancel();
            }))).subscribe();
        },
        error => {
          this.logError('Sorry, could not remove this page from Pinboard',
            error.toString());
        });
    }
    return false;
  }

  // reset error message
  reset(): boolean {
    this.error = null;
    return false;
  }

  // submit form
  submit(form: NgForm): boolean {
    if (form.valid) {
      this.save(form.value);
    }
    return false;
  }

  // save page to Pinboard
  save(value: Post): void {
    value.url = (value.url || '').trim();
    value.title = (value.title || '').trim();
    if (!value.url || !value.title) {
      return;
    }
    value.description = (value.description || '').trim() || null;
    // clean up tags, maximum of 100 tags with 255 chars each
    const tags = value.tags ? value.tags.split(' ').filter(
      tag => !!tag).slice(0, 100).map(tag => tag.slice(0, 255)) : [];
    value.tags = tags.join(' ');
    const savedTags = this.savedTags ? this.savedTags.split(' ') : [];
    this.pinboard.save(value).subscribe(
      error => {
        if (error) {
          this.logError('Sorry, could not save this page to Pinboard',
            error);
        } else {
          // update the tags in the cache
          this.pinboard.updateTagCache(tags, savedTags).pipe(finalize(
            // set the browser icon to saved state
            () => browser.tabs.query({url: this.url}).then(tabs => {
              for (const tab of tabs) {
                this.icon.setIcon(tab.id, true);
              }
              this.cancel();
            }, error => {
              console.error(error.toString());
              this.cancel();
            }))).subscribe();
        }
      },
      error => {
        this.logError('Sorry, could not save this page to Pinboard',
          error.toString());
      });
  }

  // save current tabs as tab set to Pinboard
  saveTabs(): void {
    browser.tabs.query({windowType: 'normal', url: '*://*/*'}).then(
      tabs => {
        const wTabs = {};
        for (const tab of tabs) {
          const wId = tab.windowId;
          if (!wTabs[wId]) {
            wTabs[wId] = {};
          }
          wTabs[wId][tab.index] = {title: tab.title, url: tab.url};
        }
        const windows = Object.keys(wTabs).map(
          wId => Object.keys(wTabs[wId]).map(
            index => wTabs[wId][index]));
        if (windows.length) {
          const data = {
            browser: 'ffox', windows: windows,
          };
          this.pinboard.saveTabs(data).subscribe(() => {
            this.cancel();
          }, error => {
            this.logError(
              'Sorry, could not save this tab set to Pinboard.',
              error.toString());
          });
        }
      });
  }

  // navigate to options
  settings(): void {
    // store a note that we are showing on a popup page
    this.storage.setInfo('options.page', 'popup');
    this.router.navigate(['/options']);
  }

  // log out from Pinboard
  logOut(): void {
    this.pinboard.forgetToken().subscribe(
      () => this.router.navigate(['/login']));
  }

  // show error message and log it on the console
  logError(errmsg, logmsg): void {
    if (errmsg) {
      console.error(logmsg || errmsg);
    }
    this.error = errmsg;
  }

  pinboardLink(page): boolean {
    if (page && page.indexOf('~') >= 0) {
      this.pinboard.userName.subscribe(
        name => this.pinboardLink(page.replace('~', 'u:' + name)));
    } else {
      let url = pinboardPage;
      if (page) {
        url += page;
      }
      browser.tabs.create({url: url});
      this.cancel();
    }
    return false;
  }

  // close the whole popup
  cancel(): void {
    window.close();
  }

}

{'fraction_non_alphanumeric': 0.13961594721608417, 'fraction_numerical': 0.004419408048302263, 'mean_word_length': 2.2584930534428556, 'pattern_counts': {'":': 0, '<': 13, '<?xml version=': 0, '>': 101, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '3852141', 'n_tokens_mistral': 9576, 'n_tokens_neox': 9379, 'n_words': 2592}