<commit_before>import GoTrue from 'gotrue-js';
import jwtDecode from 'jwt-decode';
import { fromPairs, get, pick, intersection, unzip } from 'lodash';
import ini from 'ini';
import { APIError, getBlobSHA, unsentRequest } from 'netlify-cms-lib-util';
import { GitHubBackend } from 'netlify-cms-backend-github';
import { GitLabBackend } from 'netlify-cms-backend-gitlab';
import { BitbucketBackend, API as BitBucketAPI } from 'netlify-cms-backend-bitbucket';
import GitHubAPI from './GitHubAPI';
import GitLabAPI from './GitLabAPI';
import AuthenticationPage from './AuthenticationPage';
import {
  parsePointerFile,
  createPointerFile,
  getLargeMediaPatternsFromGitAttributesFile,
  getClient,
} from './netlify-lfs-client';

const localHosts = {
  localhost: true,
  '127.0.0.1': true,
  '0.0.0.0': true,
};
const defaults = {
  identity: '/.netlify/identity',
  gateway: '/.netlify/git',
  largeMedia: '/.netlify/large-media',
};

function getEndpoint(endpoint, netlifySiteURL) {
  if (
    localHosts[document.location.host.split(':').shift()] &&
    netlifySiteURL &&
    endpoint.match(/^\/\.netlify\//)
  ) {
    const parts = [];
    if (netlifySiteURL) {
      parts.push(netlifySiteURL);
      if (!netlifySiteURL.match(/\/$/)) {
        parts.push('/');
      }
    }
    parts.push(endpoint.replace(/^\//, ''));
    return parts.join('');
  }
  return endpoint;
}

export default class GitGateway {
  constructor(config, options = {}) {
    this.options = {
      proxied: true,
      API: null,
      ...options,
    };
    this.config = config;
    this.branch = config.getIn(['backend', 'branch'], 'master').trim();
    this.squash_merges = config.getIn(['backend', 'squash_merges']);

    const netlifySiteURL = localStorage.getItem('netlifySiteURL');
    const APIUrl = getEndpoint(
      config.getIn(['backend', 'identity_url'], defaults.identity),
      netlifySiteURL,
    );
    this.gatewayUrl = getEndpoint(
      config.getIn(['backend', 'gateway_url'], defaults.gateway),
      netlifySiteURL,
    );
    this.netlifyLargeMediaURL = getEndpoint(
      config.getIn(['backend', 'large_media_url'], defaults.largeMedia),
      netlifySiteURL,
    );
    const backendTypeRegex = /\/(github|gitlab|bitbucket)\/?$/;
    const backendTypeMatches = this.gatewayUrl.match(backendTypeRegex);
    if (backendTypeMatches) {
      this.backendType = backendTypeMatches[1];
      this.gatewayUrl = this.gatewayUrl.replace(backendTypeRegex, '');
    } else {
      this.backendType = null;
    }

    this.authClient = window.netlifyIdentity
      ? window.netlifyIdentity.gotrue
      : new GoTrue({ APIUrl });
    AuthenticationPage.authClient = this.authClient;

    this.backend = null;
  }

  requestFunction = req =>
    this.tokenPromise()
      .then(token => unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }, req))
      .then(unsentRequest.performRequest);

  authenticate(user) {
    this.tokenPromise = user.jwt.bind(user);
    return this.tokenPromise().then(async token => {
      if (!this.backendType) {
        const { github_enabled, gitlab_enabled, bitbucket_enabled, roles } = await fetch(
          `${this.gatewayUrl}/settings`,
          {
            headers: { Authorization: `Bearer ${token}` },
          },
        ).then(async res => {
          const contentType = res.headers.get('Content-Type');
          if (!contentType.includes('application/json') && !contentType.includes('text/json')) {
            throw new APIError(
              `Your Git Gateway backend is not returning valid settings. Please make sure it is enabled.`,
              res.status,
              'Git Gateway',
            );
          }

          const body = await res.json();

          if (!res.ok) {
            throw new APIError(
              `Git Gateway Error: ${body.message ? body.message : body}`,
              res.status,
              'Git Gateway',
            );
          }

          return body;
        });
        this.acceptRoles = roles;
        if (github_enabled) {
          this.backendType = 'github';
        } else if (gitlab_enabled) {
          this.backendType = 'gitlab';
        } else if (bitbucket_enabled) {
          this.backendType = 'bitbucket';
        }
      }

      if (this.acceptRoles && this.acceptRoles.length > 0) {
        const userRoles = get(jwtDecode(token), 'app_metadata.roles', []);
        const validRole = intersection(userRoles, this.acceptRoles).length > 0;
        if (!validRole) {
          throw new Error("You don't have sufficient permissions to access Netlify CMS");
        }
      }

      const userData = {
        name: user.user_metadata.full_name || user.email.split('@').shift(),
        email: user.email,
        avatar_url: user.user_metadata.avatar_url,
        metadata: user.user_metadata,
      };
      const apiConfig = {
        api_root: `${this.gatewayUrl}/${this.backendType}`,
        branch: this.branch,
        tokenPromise: this.tokenPromise,
        commitAuthor: pick(userData, ['name', 'email']),
        squash_merges: this.squash_merges,
        initialWorkflowStatus: this.options.initialWorkflowStatus,
      };

      if (this.backendType === 'github') {
        this.api = new GitHubAPI(apiConfig);
        this.backend = new GitHubBackend(this.config, { ...this.options, API: this.api });
      } else if (this.backendType === 'gitlab') {
        this.api = new GitLabAPI(apiConfig);
        this.backend = new GitLabBackend(this.config, { ...this.options, API: this.api });
      } else if (this.backendType === 'bitbucket') {
        this.api = new BitBucketAPI({
          ...apiConfig,
          requestFunction: this.requestFunction,
          hasWriteAccess: async () => true,
        });
        this.backend = new BitbucketBackend(this.config, { ...this.options, API: this.api });
      }

      if (!(await this.api.hasWriteAccess())) {
        throw new Error("You don't have sufficient permissions to access Netlify CMS");
      }
    });
  }
  restoreUser() {
    const user = this.authClient && this.authClient.currentUser();
    if (!user) return Promise.reject();
    return this.authenticate(user);
  }
  authComponent() {
    return AuthenticationPage;
  }
  logout() {
    if (window.netlifyIdentity) {
      return window.netlifyIdentity.logout();
    }
    const user = this.authClient.currentUser();
    return user && user.logout();
  }
  getToken() {
    return this.tokenPromise();
  }

  entriesByFolder(collection, extension) {
    return this.backend.entriesByFolder(collection, extension);
  }
  entriesByFiles(collection) {
    return this.backend.entriesByFiles(collection);
  }
  fetchFiles(files) {
    return this.backend.fetchFiles(files);
  }
  getEntry(collection, slug, path) {
    return this.backend.getEntry(collection, slug, path);
  }

  getMedia() {
    return Promise.all([this.backend.getMedia(), this.getLargeMediaClient()]).then(
      async ([mediaFiles, largeMediaClient]) => {
        if (!largeMediaClient.enabled) {
          return mediaFiles;
        }
        const largeMediaDisplayURLs = await this.getLargeMediaDisplayURLs(mediaFiles);
        return mediaFiles.map(({ id, displayURL, path, ...rest }) => {
          return {
            ...rest,
            id,
            path,
            displayURL: {
              path,
              original: displayURL,
              largeMedia: largeMediaDisplayURLs[id],
            },
          };
        });
      },
    );
  }

  // this method memoizes this._getLargeMediaClient so that there can
  // only be one client at a time
  getLargeMediaClient() {
    if (this._largeMediaClientPromise) {
      return this._largeMediaClientPromise;
    }
    this._largeMediaClientPromise = this._getLargeMediaClient();
    return this._largeMediaClientPromise;
  }
  _getLargeMediaClient() {
    const netlifyLargeMediaEnabledPromise = this.api
      .readFile('.lfsconfig')
      .then(ini.decode)
      .then(({ lfs: { url } }) => new URL(url))
      .then(lfsURL => ({ enabled: lfsURL.hostname.endsWith('netlify.com') }))
      .catch(err => ({ enabled: false, err }));

    const lfsPatternsPromise = this.api
      .readFile('.gitattributes')
      .then(getLargeMediaPatternsFromGitAttributesFile)
      .then(patterns => ({ patterns }))
      .catch(err => (err.message.includes('404') ? [] : { err }));

    return Promise.all([netlifyLargeMediaEnabledPromise, lfsPatternsPromise]).then(
      ([{ enabled: maybeEnabled }, { patterns, err: patternsErr }]) => {
        const enabled = maybeEnabled && !patternsErr;

        // We expect LFS patterns to exist when the .lfsconfig states
        // that we're using Netlify Large Media
        if (maybeEnabled && patternsErr) {
          console.error(patternsErr);
        }

        return getClient({
          enabled,
          rootURL: this.netlifyLargeMediaURL,
          makeAuthorizedRequest: this.requestFunction,
          patterns,
          transformImages: this.config.getIn(
            ['backend', 'use_large_media_transforms_in_media_library'],
            true,
          )
            ? { nf_resize: 'fit', w: 560, h: 320 }
            : false,
        });
      },
    );
  }
  async getLargeMediaDisplayURLs(mediaFiles) {
    const client = await this.getLargeMediaClient();
    const largeMediaItems = mediaFiles
      .filter(({ path }) => client.matchPath(path))
      .map(({ id, path }) => ({ path, sha: id }));
    return this.backend
      .fetchFiles(largeMediaItems)
      .then(items =>
        items.map(({ file: { sha }, data }) => {
          const parsedPointerFile = parsePointerFile(data);
          return [
            {
              pointerId: sha,
              resourceId: parsedPointerFile.sha,
            },
            parsedPointerFile,
          ];
        }),
      )
      .then(unzip)
      .then(([idMaps, files]) =>
        Promise.all([idMaps, client.getResourceDownloadURLArgs(files).then(fromPairs)]),
      )
      .then(([idMaps, resourceMap]) =>
        idMaps.map(({ pointerId, resourceId }) => [pointerId, resourceMap[resourceId]]),
      )
      .then(fromPairs);
  }

  getMediaDisplayURL(displayURL) {
    const { path, original, largeMedia: largeMediaDisplayURL } = displayURL;
    return this.getLargeMediaClient().then(client => {
      if (client.enabled && client.matchPath(path)) {
        return client.getDownloadURL(largeMediaDisplayURL);
      }
      if (typeof original === 'string') {
        return original;
      }
      if (this.backend.getMediaDisplayURL) {
        return this.backend.getMediaDisplayURL(original);
      }
      const err = new Error(
        `getMediaDisplayURL is not implemented by the ${
          this.backendType
        } backend, but the backend returned a displayURL which was not a string!`,
      );
      err.displayURL = displayURL;
      return Promise.reject(err);
    });
  }

  async getPointerFileForMediaFileObj(fileObj) {
    const client = await this.getLargeMediaClient();
    const { name, size } = fileObj;
    const sha = await getBlobSHA(fileObj);
    await client.uploadResource({ sha, size }, fileObj);
    const pointerFileString = createPointerFile({ sha, size });
    const pointerFileBlob = new Blob([pointerFileString]);
    const pointerFile = new File([pointerFileBlob], name, { type: 'text/plain' });
    const pointerFileSHA = await getBlobSHA(pointerFile);
    return {
      file: pointerFile,
      blob: pointerFileBlob,
      sha: pointerFileSHA,
      raw: pointerFileString,
    };
  }

  async persistEntry(entry, mediaFiles, options) {
    const client = await this.getLargeMediaClient();
    if (!client.enabled) {
      return this.backend.persistEntry(entry, mediaFiles, options);
    }

    const largeMediaFilteredMediaFiles = await Promise.all(
      mediaFiles.map(async mediaFile => {
        const { fileObj, path } = mediaFile;
        const fixedPath = path.startsWith('/') ? path.slice(1) : path;
        if (!client.matchPath(fixedPath)) {
          return mediaFile;
        }

        const pointerFileDetails = await this.getPointerFileForMediaFileObj(fileObj);
        return {
          ...mediaFile,
          fileObj: pointerFileDetails.file,
          size: pointerFileDetails.blob.size,
          sha: pointerFileDetails.sha,
          raw: pointerFileDetails.raw,
        };
      }),
    );

    return this.backend.persistEntry(entry, largeMediaFilteredMediaFiles, options);
  }

  async persistMedia(mediaFile, options) {
    const { fileObj, path, value } = mediaFile;
    const displayURL = URL.createObjectURL(fileObj);
    const client = await this.getLargeMediaClient();
    const fixedPath = path.startsWith('/') ? path.slice(1) : path;
    if (!client.enabled || !client.matchPath(fixedPath)) {
      return this.backend.persistMedia(mediaFile, options);
    }

    const pointerFileDetails = await this.getPointerFileForMediaFileObj(fileObj);
    const persistMediaArgument = {
      fileObj: pointerFileDetails.file,
      size: pointerFileDetails.blob.size,
      path,
      sha: pointerFileDetails.sha,
      raw: pointerFileDetails.raw,
      value,
    };
    return {
      ...(await this.backend.persistMedia(persistMediaArgument, options)),
      displayURL,
    };
  }
  deleteFile(path, commitMessage, options) {
    return this.backend.deleteFile(path, commitMessage, options);
  }
  getDeployPreview(collection, slug) {
    if (this.backend.getDeployPreview) {
      return this.backend.getDeployPreview(collection, slug);
    }
  }
  unpublishedEntries() {
    return this.backend.unpublishedEntries();
  }
  unpublishedEntry(collection, slug) {
    return this.backend.unpublishedEntry(collection, slug);
  }
  updateUnpublishedEntryStatus(collection, slug, newStatus) {
    return this.backend.updateUnpublishedEntryStatus(collection, slug, newStatus);
  }
  deleteUnpublishedEntry(collection, slug) {
    return this.backend.deleteUnpublishedEntry(collection, slug);
  }
  publishUnpublishedEntry(collection, slug) {
    return this.backend.publishUnpublishedEntry(collection, slug);
  }
  traverseCursor(cursor, action) {
    return this.backend.traverseCursor(cursor, action);
  }
}
<commit_msg>fix(backend-git-gateway): fix image display w/o large media (#2271)<commit_after>import GoTrue from 'gotrue-js';
import jwtDecode from 'jwt-decode';
import { fromPairs, get, pick, intersection, unzip } from 'lodash';
import ini from 'ini';
import { APIError, getBlobSHA, unsentRequest } from 'netlify-cms-lib-util';
import { GitHubBackend } from 'netlify-cms-backend-github';
import { GitLabBackend } from 'netlify-cms-backend-gitlab';
import { BitbucketBackend, API as BitBucketAPI } from 'netlify-cms-backend-bitbucket';
import GitHubAPI from './GitHubAPI';
import GitLabAPI from './GitLabAPI';
import AuthenticationPage from './AuthenticationPage';
import {
  parsePointerFile,
  createPointerFile,
  getLargeMediaPatternsFromGitAttributesFile,
  getClient,
} from './netlify-lfs-client';

const localHosts = {
  localhost: true,
  '127.0.0.1': true,
  '0.0.0.0': true,
};
const defaults = {
  identity: '/.netlify/identity',
  gateway: '/.netlify/git',
  largeMedia: '/.netlify/large-media',
};

function getEndpoint(endpoint, netlifySiteURL) {
  if (
    localHosts[document.location.host.split(':').shift()] &&
    netlifySiteURL &&
    endpoint.match(/^\/\.netlify\//)
  ) {
    const parts = [];
    if (netlifySiteURL) {
      parts.push(netlifySiteURL);
      if (!netlifySiteURL.match(/\/$/)) {
        parts.push('/');
      }
    }
    parts.push(endpoint.replace(/^\//, ''));
    return parts.join('');
  }
  return endpoint;
}

export default class GitGateway {
  constructor(config, options = {}) {
    this.options = {
      proxied: true,
      API: null,
      ...options,
    };
    this.config = config;
    this.branch = config.getIn(['backend', 'branch'], 'master').trim();
    this.squash_merges = config.getIn(['backend', 'squash_merges']);

    const netlifySiteURL = localStorage.getItem('netlifySiteURL');
    const APIUrl = getEndpoint(
      config.getIn(['backend', 'identity_url'], defaults.identity),
      netlifySiteURL,
    );
    this.gatewayUrl = getEndpoint(
      config.getIn(['backend', 'gateway_url'], defaults.gateway),
      netlifySiteURL,
    );
    this.netlifyLargeMediaURL = getEndpoint(
      config.getIn(['backend', 'large_media_url'], defaults.largeMedia),
      netlifySiteURL,
    );
    const backendTypeRegex = /\/(github|gitlab|bitbucket)\/?$/;
    const backendTypeMatches = this.gatewayUrl.match(backendTypeRegex);
    if (backendTypeMatches) {
      this.backendType = backendTypeMatches[1];
      this.gatewayUrl = this.gatewayUrl.replace(backendTypeRegex, '');
    } else {
      this.backendType = null;
    }

    this.authClient = window.netlifyIdentity
      ? window.netlifyIdentity.gotrue
      : new GoTrue({ APIUrl });
    AuthenticationPage.authClient = this.authClient;

    this.backend = null;
  }

  requestFunction = req =>
    this.tokenPromise()
      .then(token => unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }, req))
      .then(unsentRequest.performRequest);

  authenticate(user) {
    this.tokenPromise = user.jwt.bind(user);
    return this.tokenPromise().then(async token => {
      if (!this.backendType) {
        const { github_enabled, gitlab_enabled, bitbucket_enabled, roles } = await fetch(
          `${this.gatewayUrl}/settings`,
          {
            headers: { Authorization: `Bearer ${token}` },
          },
        ).then(async res => {
          const contentType = res.headers.get('Content-Type');
          if (!contentType.includes('application/json') && !contentType.includes('text/json')) {
            throw new APIError(
              `Your Git Gateway backend is not returning valid settings. Please make sure it is enabled.`,
              res.status,
              'Git Gateway',
            );
          }

          const body = await res.json();

          if (!res.ok) {
            throw new APIError(
              `Git Gateway Error: ${body.message ? body.message : body}`,
              res.status,
              'Git Gateway',
            );
          }

          return body;
        });
        this.acceptRoles = roles;
        if (github_enabled) {
          this.backendType = 'github';
        } else if (gitlab_enabled) {
          this.backendType = 'gitlab';
        } else if (bitbucket_enabled) {
          this.backendType = 'bitbucket';
        }
      }

      if (this.acceptRoles && this.acceptRoles.length > 0) {
        const userRoles = get(jwtDecode(token), 'app_metadata.roles', []);
        const validRole = intersection(userRoles, this.acceptRoles).length > 0;
        if (!validRole) {
          throw new Error("You don't have sufficient permissions to access Netlify CMS");
        }
      }

      const userData = {
        name: user.user_metadata.full_name || user.email.split('@').shift(),
        email: user.email,
        avatar_url: user.user_metadata.avatar_url,
        metadata: user.user_metadata,
      };
      const apiConfig = {
        api_root: `${this.gatewayUrl}/${this.backendType}`,
        branch: this.branch,
        tokenPromise: this.tokenPromise,
        commitAuthor: pick(userData, ['name', 'email']),
        squash_merges: this.squash_merges,
        initialWorkflowStatus: this.options.initialWorkflowStatus,
      };

      if (this.backendType === 'github') {
        this.api = new GitHubAPI(apiConfig);
        this.backend = new GitHubBackend(this.config, { ...this.options, API: this.api });
      } else if (this.backendType === 'gitlab') {
        this.api = new GitLabAPI(apiConfig);
        this.backend = new GitLabBackend(this.config, { ...this.options, API: this.api });
      } else if (this.backendType === 'bitbucket') {
        this.api = new BitBucketAPI({
          ...apiConfig,
          requestFunction: this.requestFunction,
          hasWriteAccess: async () => true,
        });
        this.backend = new BitbucketBackend(this.config, { ...this.options, API: this.api });
      }

      if (!(await this.api.hasWriteAccess())) {
        throw new Error("You don't have sufficient permissions to access Netlify CMS");
      }
    });
  }
  restoreUser() {
    const user = this.authClient && this.authClient.currentUser();
    if (!user) return Promise.reject();
    return this.authenticate(user);
  }
  authComponent() {
    return AuthenticationPage;
  }
  logout() {
    if (window.netlifyIdentity) {
      return window.netlifyIdentity.logout();
    }
    const user = this.authClient.currentUser();
    return user && user.logout();
  }
  getToken() {
    return this.tokenPromise();
  }

  entriesByFolder(collection, extension) {
    return this.backend.entriesByFolder(collection, extension);
  }
  entriesByFiles(collection) {
    return this.backend.entriesByFiles(collection);
  }
  fetchFiles(files) {
    return this.backend.fetchFiles(files);
  }
  getEntry(collection, slug, path) {
    return this.backend.getEntry(collection, slug, path);
  }

  getMedia() {
    return Promise.all([this.backend.getMedia(), this.getLargeMediaClient()]).then(
      async ([mediaFiles, largeMediaClient]) => {
        if (!largeMediaClient.enabled) {
          return mediaFiles.map(({ displayURL, ...rest }) => ({
            ...rest,
            displayURL: { original: displayURL },
          }));
        }
        const largeMediaDisplayURLs = await this.getLargeMediaDisplayURLs(mediaFiles);
        return mediaFiles.map(({ id, displayURL, path, ...rest }) => {
          return {
            ...rest,
            id,
            path,
            displayURL: {
              path,
              original: displayURL,
              largeMedia: largeMediaDisplayURLs[id],
            },
          };
        });
      },
    );
  }

  // this method memoizes this._getLargeMediaClient so that there can
  // only be one client at a time
  getLargeMediaClient() {
    if (this._largeMediaClientPromise) {
      return this._largeMediaClientPromise;
    }
    this._largeMediaClientPromise = this._getLargeMediaClient();
    return this._largeMediaClientPromise;
  }
  _getLargeMediaClient() {
    const netlifyLargeMediaEnabledPromise = this.api
      .readFile('.lfsconfig')
      .then(ini.decode)
      .then(({ lfs: { url } }) => new URL(url))
      .then(lfsURL => ({ enabled: lfsURL.hostname.endsWith('netlify.com') }))
      .catch(err => ({ enabled: false, err }));

    const lfsPatternsPromise = this.api
      .readFile('.gitattributes')
      .then(getLargeMediaPatternsFromGitAttributesFile)
      .then(patterns => ({ patterns }))
      .catch(err => (err.message.includes('404') ? [] : { err }));

    return Promise.all([netlifyLargeMediaEnabledPromise, lfsPatternsPromise]).then(
      ([{ enabled: maybeEnabled }, { patterns, err: patternsErr }]) => {
        const enabled = maybeEnabled && !patternsErr;

        // We expect LFS patterns to exist when the .lfsconfig states
        // that we're using Netlify Large Media
        if (maybeEnabled && patternsErr) {
          console.error(patternsErr);
        }

        return getClient({
          enabled,
          rootURL: this.netlifyLargeMediaURL,
          makeAuthorizedRequest: this.requestFunction,
          patterns,
          transformImages: this.config.getIn(
            ['backend', 'use_large_media_transforms_in_media_library'],
            true,
          )
            ? { nf_resize: 'fit', w: 560, h: 320 }
            : false,
        });
      },
    );
  }
  async getLargeMediaDisplayURLs(mediaFiles) {
    const client = await this.getLargeMediaClient();
    const largeMediaItems = mediaFiles
      .filter(({ path }) => client.matchPath(path))
      .map(({ id, path }) => ({ path, sha: id }));
    return this.backend
      .fetchFiles(largeMediaItems)
      .then(items =>
        items.map(({ file: { sha }, data }) => {
          const parsedPointerFile = parsePointerFile(data);
          return [
            {
              pointerId: sha,
              resourceId: parsedPointerFile.sha,
            },
            parsedPointerFile,
          ];
        }),
      )
      .then(unzip)
      .then(([idMaps, files]) =>
        Promise.all([idMaps, client.getResourceDownloadURLArgs(files).then(fromPairs)]),
      )
      .then(([idMaps, resourceMap]) =>
        idMaps.map(({ pointerId, resourceId }) => [pointerId, resourceMap[resourceId]]),
      )
      .then(fromPairs);
  }

  getMediaDisplayURL(displayURL) {
    const { path, original, largeMedia: largeMediaDisplayURL } = displayURL;
    return this.getLargeMediaClient().then(client => {
      if (client.enabled && client.matchPath(path)) {
        return client.getDownloadURL(largeMediaDisplayURL);
      }
      if (typeof original === 'string') {
        return original;
      }
      if (this.backend.getMediaDisplayURL) {
        return this.backend.getMediaDisplayURL(original);
      }
      const err = new Error(
        `getMediaDisplayURL is not implemented by the ${
          this.backendType
        } backend, but the backend returned a displayURL which was not a string!`,
      );
      err.displayURL = displayURL;
      return Promise.reject(err);
    });
  }

  async getPointerFileForMediaFileObj(fileObj) {
    const client = await this.getLargeMediaClient();
    const { name, size } = fileObj;
    const sha = await getBlobSHA(fileObj);
    await client.uploadResource({ sha, size }, fileObj);
    const pointerFileString = createPointerFile({ sha, size });
    const pointerFileBlob = new Blob([pointerFileString]);
    const pointerFile = new File([pointerFileBlob], name, { type: 'text/plain' });
    const pointerFileSHA = await getBlobSHA(pointerFile);
    return {
      file: pointerFile,
      blob: pointerFileBlob,
      sha: pointerFileSHA,
      raw: pointerFileString,
    };
  }

  async persistEntry(entry, mediaFiles, options) {
    const client = await this.getLargeMediaClient();
    if (!client.enabled) {
      return this.backend.persistEntry(entry, mediaFiles, options);
    }

    const largeMediaFilteredMediaFiles = await Promise.all(
      mediaFiles.map(async mediaFile => {
        const { fileObj, path } = mediaFile;
        const fixedPath = path.startsWith('/') ? path.slice(1) : path;
        if (!client.matchPath(fixedPath)) {
          return mediaFile;
        }

        const pointerFileDetails = await this.getPointerFileForMediaFileObj(fileObj);
        return {
          ...mediaFile,
          fileObj: pointerFileDetails.file,
          size: pointerFileDetails.blob.size,
          sha: pointerFileDetails.sha,
          raw: pointerFileDetails.raw,
        };
      }),
    );

    return this.backend.persistEntry(entry, largeMediaFilteredMediaFiles, options);
  }

  async persistMedia(mediaFile, options) {
    const { fileObj, path, value } = mediaFile;
    const displayURL = URL.createObjectURL(fileObj);
    const client = await this.getLargeMediaClient();
    const fixedPath = path.startsWith('/') ? path.slice(1) : path;
    if (!client.enabled || !client.matchPath(fixedPath)) {
      return this.backend.persistMedia(mediaFile, options);
    }

    const pointerFileDetails = await this.getPointerFileForMediaFileObj(fileObj);
    const persistMediaArgument = {
      fileObj: pointerFileDetails.file,
      size: pointerFileDetails.blob.size,
      path,
      sha: pointerFileDetails.sha,
      raw: pointerFileDetails.raw,
      value,
    };
    return {
      ...(await this.backend.persistMedia(persistMediaArgument, options)),
      displayURL,
    };
  }
  deleteFile(path, commitMessage, options) {
    return this.backend.deleteFile(path, commitMessage, options);
  }
  getDeployPreview(collection, slug) {
    if (this.backend.getDeployPreview) {
      return this.backend.getDeployPreview(collection, slug);
    }
  }
  unpublishedEntries() {
    return this.backend.unpublishedEntries();
  }
  unpublishedEntry(collection, slug) {
    return this.backend.unpublishedEntry(collection, slug);
  }
  updateUnpublishedEntryStatus(collection, slug, newStatus) {
    return this.backend.updateUnpublishedEntryStatus(collection, slug, newStatus);
  }
  deleteUnpublishedEntry(collection, slug) {
    return this.backend.deleteUnpublishedEntry(collection, slug);
  }
  publishUnpublishedEntry(collection, slug) {
    return this.backend.publishUnpublishedEntry(collection, slug);
  }
  traverseCursor(cursor, action) {
    return this.backend.traverseCursor(cursor, action);
  }
}
<|endoftext|>


<commit_before>// @ts-nocheck
import { define, props } from 'skatejs';
import { h, withPreact } from '@bolt/core/renderers';
import Fuse from 'fuse.js';
import ReactHtmlParser from 'react-html-parser';
import Mousetrap from 'mousetrap';
import Autosuggest from 'react-autosuggest';
import { bind } from './classnames';

import styles from './typeahead.scoped.scss';
const cx = bind(styles);

export const highlightSearchResults = function(item) {
  const resultItem = item;
  resultItem.matches.forEach(matchItem => {
    const text = resultItem.item[matchItem.key];
    const result = [];
    const matches = [].concat(matchItem.indices);
    let pair = matches.shift();

    for (let i = 0; i < text.length; i++) {
      const char = text.charAt(i);
      if (pair && i === pair[0]) {
        result.push(
          `<strong class="${cx('c-bolt-typeahead__result-highlight')}">`,
        );
      }
      result.push(char);
      if (pair && i === pair[1]) {
        result.push('');
        pair = matches.shift();
      }
    }
    resultItem.item.highlightedLabel = result.join('');

    resultItem.item.highlightedLabel = ReactHtmlParser(
      resultItem.item.highlightedLabel,
    );

    if (resultItem.children && resultItem.children.length > 0) {
      resultItem.children.forEach(child => {
        highlightSearchResults(child);
      });
    }
  });
};

@define
class BoltAutosuggest extends withPreact() {
  static is = 'bolt-autosuggest';

  // subscribe to specific props that are defined and available on the parent container
  // (context + subscriber idea originally from https://codepen.io/trusktr/project/editor/XbEOMk)
  // static get consumes() {
  //   return [[TypeaheadContext, 'items']];
  // }

  static props = {
    placeholder: props.string,
    noClearButton: props.boolean,
    clearButtonText: props.string,
    fallbackMethod: props.string,
    fallbackTarget: props.string,
    fallbackAction: props.string,
    value: props.string,
    submitButtonText: {
      ...props.string,
      ...{ default: 'Submit' },
    },
    maxResults: {
      ...props.string,
      ...{ default: 10 },
    },
  };

  connecting() {
    super.connecting && super.connecting();
    // Keep an object of listener types mapped to callback functions
    this._listeners = {};
  }

  disconnecting() {
    super.disconnecting && super.disconnecting();
    // Keep an object of listener types mapped to callback functions
    this._listeners = {};
  }

  /**
   * Register a new callback for the given event type
   *
   * @param {string} type
   * @param {Function} handler
   */
  on(type, handler) {
    if (
      typeof this.closest('bolt-typeahead')._listeners[type] === 'undefined'
    ) {
      this.closest('bolt-typeahead')._listeners[type] = [];
    }

    this.closest('bolt-typeahead')._listeners[type].push(handler);

    return this;
  }

  /**
   * Unregister an existing callback for the given event type
   *
   * @param {string} type
   * @param {Function} handler
   */
  off(type, handler) {
    var index = this.closest('bolt-typeahead')._listeners[type].indexOf(
      handler,
    );

    if (index > -1) {
      this.closest('bolt-typeahead')._listeners[type].splice(index, 1);
    }

    return this;
  }

  /**
   * Iterate over all registered handlers for given type and call them all with
   * the dialog element as first argument, event as second argument (if any).
   *
   * @access private
   * @param {string} type
   * @param {Event} event
   */
  _fire(type, ...props) {
    var listeners = this.closest('bolt-typeahead')._listeners[type] || [];

    listeners.forEach(
      function(listener) {
        listener(this, ...props);
      }.bind(this),
    );
  }

  // @ts-ignore
  constructor() {
    super();
    // self.useShadow = false;
    // Autosuggest is a controlled component.
    // This means that you need to provide an input value
    // and an onChange handler that updates this value (see below).
    // Suggestions also need to be provided to the Autosuggest,
    // and they are initially empty because the Autosuggest is closed.
    this.state = {
      value: '',
      suggestions: [],
    };

    // self.onChange = self.onChange.bind(self);
    this.toggleSearch = this.toggleSearch.bind(this);
    this.clearSearch = this.clearSearch.bind(this);
    this.closeSearch = this.closeSearch.bind(this);
    this.renderInputComponent = this.renderInputComponent.bind(this);
    this.openSearch = this.openSearch.bind(this);
    this.onSelected = this.onSelected.bind(this);
  }

  connectedCallback() {
    super.connectedCallback();
    // this.context = this.contexts.get(TypeaheadContext);

    // Inherit items from `bolt-typeahead`
    // this.items = this.closest('bolt-typeahead').items || this.items || [];
  }

  connected() {
    super.connected && super.connected();

    this._externalInputElement = this.querySelector('input[type="text"]');
    if (this._externalInputElement) {
      this._setState({
        value: this._externalInputElement.value || '',
      });
    }

    const self = this;
    Mousetrap.bind('command+shift+f', function(e) {
      e.preventDefault();
      self.toggleSearch();
    });
  }

  _updateInputFallback(newValue) {
    if (this.hasExternalInput) {
      // this.hasExternalInput.value = value;
      this.hasExternalInput.setAttribute('value', newValue);
    }
  }

  _setState(newValue) {
    super.setState && super.setState(newValue);

    if (newValue.value) {
      this.value = newValue.value;
      this._updateInputFallback(newValue.value);
    } else if (newValue.value === '') {
      this.value = '';
      this._updateInputFallback('');
    }
  }

  _stateChanged(state) {
    // throw new Error('_stateChanged() not implemented', this);
    this.triggerUpdate();
  }

  onInput = e => {
    this._fire('onInput', e, e.target.value);
    let value = e.target.value;

    this._setState({
      value,
    });

    // skip default onInput behavior if external listeners have hooked in
    // if (this._listeners['onInput']) {
    //   return;
    // }

    this.onSuggestionsFetchRequested({ value }); // re-render search results immediately based on latest input value
  };

  toggleSearch() {
    if (!this.state.isOpen) {
      this.openSearch();
    } else {
      this.closeSearch();
    }
  }

  clearSearch() {
    this._inputElement.focus();
    this._setState({
      value: '',
    });
  }

  openSearch() {
    this._inputElement.focus();
  }

  closeSearch() {
    document.activeElement.blur();
  }

  getSuggestionValue = suggestion => suggestion.label;

  // highlights keywords in the search results in a react-friendly way + limits the total number of results displayed
  getSuggestions(value) {
    this._fire('getSuggestions', value);

    const items = this.closest('bolt-typeahead').items;
    this.items = items;
    // console.log(this.context.items);

    // skip default onChange behavior if external listeners have hooked in
    // if (!this._listeners['getSuggestions']) {
    const fuseOptions = {
      shouldSort: true,
      threshold: 0.3,
      tokenize: true,
      includeMatches: true,
      location: 0,
      distance: 100,
      maxPatternLength: 32,
      minMatchCharLength: 1,
      keys: ['label', 'description'],
    };
    const fuse = new Fuse(items, fuseOptions);
    const results = fuse.search(value);

    results.forEach(resultItem => {
      highlightSearchResults(resultItem);
    });

    const reducedResults = results.reduce((total, result) => {
      total.push(result.item);
      return total;
    }, []);

    if (reducedResults.length < this.props.maxResults) {
      return reducedResults;
    } else {
      return reducedResults.slice(0, this.props.maxResults);
    }
    // }
  }

  /**
   * Autosuggest calls this when a search result is selected
   * @event onChange
   * @param {event} event - The onChange event emitted
   * @param {{newValue: string}} newValue - the updated input value
   */
  onChange = (event, { newValue, method }) => {
    // console.log(newValue);
    this._fire('onChange', event, newValue, method);
    this._setState({ value: newValue });
  };

  // Autosuggest calls this every time you need to update suggestions.
  onSuggestionsFetchRequested = ({ value }) => {
    this._fire('onSuggestionsFetchRequested', value);
    this._setState({ isOpen: true });

    this._setState({
      suggestions: this.getSuggestions(value),
    });
  };

  // Autosuggest calls this every time you need to clear suggestions.
  onSuggestionsClearRequested = () => {
    this._fire('onSuggestionsClearRequested');
    this._setState({
      suggestions: [],
      isOpen: false,
    });
  };

  // maps to our custom onSelected event hook
  onSelected(event, suggestion) {
    this._fire('onSelected', event, suggestion);
    this._setState({ value: suggestion.suggestionValue });
    this.closeSearch();
  }

  renderSuggestion(suggestion, { query, isHighlighted }) {
    return (

        {suggestion.label}

    );
  }

  /**
   * Customizes the rendering of the input.
   * @param {object} inputProps - Props passed to the rendered input. https://github.com/moroshko/react-autosuggest#inputprops-required
   */
  renderInputComponent(inputProps) {
    const { value } = this.state;

    this._fire('onRenderInput', value);

    // console.log(value);

    const shouldShowClearButton = true;
    //
    //   this.props.noClearButton !== undefined &&
    //   this.props.noClearButton !== true &&
    //   value !== '';

    // const clearButtonText = this.props.clearButtonText
    //   ? this.props.clearButtonText
    //   : 'Clear Search Results';

    // const submitButtonText = this.props.submitButtonText
    //   ? this.props.submitButtonText
    //   : 'Submit';

    return (
      <div
        className={cx(`c-bolt-typeahead__input-wrapper`, {
          [`c-bolt-typeahead__input-wrapper--with-clear-button`]: shouldShowClearButton,
        })}>
        <input {...inputProps} />
        {/* {shouldShowClearButton && (
          <bolt-button
            dangerouslySetInnerHTML="foo"
            color="text"
            icon-only
            type="reset"
            className={cx(
              'c-bolt-typeahead__button',
              'c-bolt-typeahead__button--clear',
              {
                [`is-visible`]: value !== '',
              },
            )}
            onClick={() => {
              this.clearSearch();
            }}>
            {clearButtonText}
            <bolt-icon
              name="close-solid"
              slot="before"
              className={cx('c-bolt-typeahead__icon')}
              title={clearButtonText}
            />
          </bolt-button>
        )}
        <bolt-button
          dangerouslySetInnerHTML="foo"
          type="submit"
          color="text"
          icon-only
          onClick={() => {
            this.querySelector('form')
              ? this.querySelector('form').submit()
              : '';
          }}
          className={cx(
            'c-bolt-typeahead__button',
            'c-bolt-typeahead__button--submit',
          )}>
          {submitButtonText}
          <bolt-icon
            name="search"
            className={cx('c-bolt-typeahead__icon')}
            title={submitButtonText}
            slot="before"
          />
        </bolt-button> */}

    );
  }

  render() {
    const { suggestions } = this.state;

    const shouldShowClearButton =
      (this.props.noClearButton !== undefined &&
        this.props.noClearButton !== true &&
        this.state.value !== '') ||
      this.value !== '';

    const theme = {
      container: cx('c-bolt-typeahead'),
      containerOpen: cx('c-bolt-typeahead--open'),
      input: cx('c-bolt-typeahead__input', 'js-c-typeahead__input', {
        [`c-bolt-typeahead__input--with-clear-button`]: shouldShowClearButton,
      }),
      inputOpen: cx('c-bolt-typeahead__input--open'),
      inputFocused: cx('c-bolt-typeahead__input--focused'),
      suggestionsContainer: cx('c-bolt-typeahead__menu'),
      suggestionsContainerOpen: cx('is-open'),
      suggestionsList: cx('c-bolt-typeahead__results'),
      suggestion: cx('c-bolt-typeahead__result'),
      suggestionFirst: cx('c-bolt-typeahead__result--first'),
      suggestionHighlighted: cx('has-cursor'),
      sectionContainer: cx('c-bolt-typeahead__section-container'),
      sectionContainerFirst: cx('c-bolt-typeahead__section-container--first'),
      sectionTitle: cx('c-bolt-typeahead__section-title'),
    };

    // Autosuggest will pass through all these props to the input.
    const inputProps = {
      placeholder: this.props.placeholder,
      value: this.state.value || this.value,
      onChange: this.onChange,
      onInput: this.onInput,
    };

    return (
      <div
        class={cx('c-bolt-typeahead__wrapper')}
        style={`--typeahead-height: ${this.offsetHeight}px;`}>
        {this.addStyles([styles])}
        <Autosuggest
          theme={theme}
          suggestions={suggestions}
          inputProps={inputProps}
          getSuggestionValue={this.getSuggestionValue}
          onSuggestionSelected={this.onSelected}
          onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
          onSuggestionsClearRequested={this.onSuggestionsClearRequested}
          renderSuggestion={this.renderSuggestion}
          renderInputComponent={this.renderInputComponent}
        />

    );
  }

  rendered() {
    super.rendered && super.rendered();
    this._inputElement = this.renderRoot.querySelector(
      '.js-c-typeahead__input',
    );
  }
}

export { BoltAutosuggest };
<commit_msg>refactor: grab parent element reference based on rendering to Shadow DOM or not<commit_after>// @ts-nocheck
import { define, props } from 'skatejs';
import { h, withPreact } from '@bolt/core/renderers';
import Fuse from 'fuse.js';
import ReactHtmlParser from 'react-html-parser';
import Mousetrap from 'mousetrap';
import Autosuggest from 'react-autosuggest';
import { bind } from './classnames';

import styles from './typeahead.scoped.scss';
const cx = bind(styles);

export const highlightSearchResults = function(item) {
  const resultItem = item;
  resultItem.matches.forEach(matchItem => {
    const text = resultItem.item[matchItem.key];
    const result = [];
    const matches = [].concat(matchItem.indices);
    let pair = matches.shift();

    for (let i = 0; i < text.length; i++) {
      const char = text.charAt(i);
      if (pair && i === pair[0]) {
        result.push(
          `<strong class="${cx('c-bolt-typeahead__result-highlight')}">`,
        );
      }
      result.push(char);
      if (pair && i === pair[1]) {
        result.push('');
        pair = matches.shift();
      }
    }
    resultItem.item.highlightedLabel = result.join('');

    resultItem.item.highlightedLabel = ReactHtmlParser(
      resultItem.item.highlightedLabel,
    );

    if (resultItem.children && resultItem.children.length > 0) {
      resultItem.children.forEach(child => {
        highlightSearchResults(child);
      });
    }
  });
};

@define
class BoltAutosuggest extends withPreact() {
  static is = 'bolt-autosuggest';

  // subscribe to specific props that are defined and available on the parent container
  // (context + subscriber idea originally from https://codepen.io/trusktr/project/editor/XbEOMk)
  // static get consumes() {
  //   return [[TypeaheadContext, 'items']];
  // }

  static props = {
    placeholder: props.string,
    noClearButton: props.boolean,
    clearButtonText: props.string,
    fallbackMethod: props.string,
    fallbackTarget: props.string,
    fallbackAction: props.string,
    value: props.string,
    submitButtonText: {
      ...props.string,
      ...{ default: 'Submit' },
    },
    maxResults: {
      ...props.string,
      ...{ default: 10 },
    },
  };

  connecting() {
    super.connecting && super.connecting();
    // Keep an object of listener types mapped to callback functions
    this._listeners = {};
  }

  disconnecting() {
    super.disconnecting && super.disconnecting();
    // Keep an object of listener types mapped to callback functions
    this._listeners = {};
  }

  // return the parent that's rendering <bolt-autosuggest> based on Shadow DOM usage
  get getParent(){
    if (this.shadowRoot && this.shadowRoot.host) {
      return this.shadowRoot.host;
    } else {
      return this.closest('bolt-typeahead');
    }
  }

  /**
   * Register a new callback for the given event type
   *
   * @param {string} type
   * @param {Function} handler
   */
  on(type, handler) {
    if (typeof this.getParent._listeners[type] === 'undefined') {
      this.getParent._listeners[type] = [];
    }

    this.getParent._listeners[type].push(handler);

    return this;
  }

  /**
   * Unregister an existing callback for the given event type
   *
   * @param {string} type
   * @param {Function} handler
   */
  off(type, handler) {
    var index = this.getParent._listeners[type].indexOf(handler);

    if (index > -1) {
      this.getParent._listeners[type].splice(index, 1);
    }

    return this;
  }

  /**
   * Iterate over all registered handlers for given type and call them all with
   * the dialog element as first argument, event as second argument (if any).
   *
   * @access private
   * @param {string} type
   * @param {Event} event
   */
  _fire(type, ...props) {
    var listeners = this.getParent._listeners[type] || [];

    listeners.forEach(
      function(listener) {
        listener(this, ...props);
      }.bind(this),
    );
  }

  // @ts-ignore
  constructor() {
    super();
    // self.useShadow = false;
    // Autosuggest is a controlled component.
    // This means that you need to provide an input value
    // and an onChange handler that updates this value (see below).
    // Suggestions also need to be provided to the Autosuggest,
    // and they are initially empty because the Autosuggest is closed.
    this.state = {
      value: '',
      suggestions: [],
    };

    // self.onChange = self.onChange.bind(self);
    this.toggleSearch = this.toggleSearch.bind(this);
    this.clearSearch = this.clearSearch.bind(this);
    this.closeSearch = this.closeSearch.bind(this);
    this.renderInputComponent = this.renderInputComponent.bind(this);
    this.openSearch = this.openSearch.bind(this);
    this.onSelected = this.onSelected.bind(this);
  }

  connectedCallback() {
    super.connectedCallback();
    // this.context = this.contexts.get(TypeaheadContext);

    // Inherit items from `bolt-typeahead`
    // this.items = this.getParent.items || this.items || [];
  }

  connected() {
    super.connected && super.connected();

    this._externalInputElement = this.querySelector('input[type="text"]');
    if (this._externalInputElement) {
      this._setState({
        value: this._externalInputElement.value || '',
      });
    }

    const self = this;
    Mousetrap.bind('command+shift+f', function(e) {
      e.preventDefault();
      self.toggleSearch();
    });
  }

  _updateInputFallback(newValue) {
    if (this.hasExternalInput) {
      // this.hasExternalInput.value = value;
      this.hasExternalInput.setAttribute('value', newValue);
    }
  }

  _setState(newValue) {
    super.setState && super.setState(newValue);

    if (newValue.value) {
      this.value = newValue.value;
      this._updateInputFallback(newValue.value);
    } else if (newValue.value === '') {
      this.value = '';
      this._updateInputFallback('');
    }
  }

  _stateChanged(state) {
    // throw new Error('_stateChanged() not implemented', this);
    this.triggerUpdate();
  }

  onInput = e => {
    this._fire('onInput', e, e.target.value);
    let value = e.target.value;

    this._setState({
      value,
    });

    // skip default onInput behavior if external listeners have hooked in
    // if (this._listeners['onInput']) {
    //   return;
    // }

    this.onSuggestionsFetchRequested({ value }); // re-render search results immediately based on latest input value
  };

  toggleSearch() {
    if (!this.state.isOpen) {
      this.openSearch();
    } else {
      this.closeSearch();
    }
  }

  clearSearch() {
    this._inputElement.focus();
    this._setState({
      value: '',
    });
  }

  openSearch() {
    this._inputElement.focus();
  }

  closeSearch() {
    document.activeElement.blur();
  }

  getSuggestionValue = suggestion => suggestion.label;

  // highlights keywords in the search results in a react-friendly way + limits the total number of results displayed
  getSuggestions(value) {
    this._fire('getSuggestions', value);

    const items = this.getParent.items;
    this.items = items;
    // console.log(this.context.items);

    // skip default onChange behavior if external listeners have hooked in
    // if (!this._listeners['getSuggestions']) {
    const fuseOptions = {
      shouldSort: true,
      threshold: 0.3,
      tokenize: true,
      includeMatches: true,
      location: 0,
      distance: 100,
      maxPatternLength: 32,
      minMatchCharLength: 1,
      keys: ['label', 'description'],
    };
    const fuse = new Fuse(items, fuseOptions);
    const results = fuse.search(value);

    results.forEach(resultItem => {
      highlightSearchResults(resultItem);
    });

    const reducedResults = results.reduce((total, result) => {
      total.push(result.item);
      return total;
    }, []);

    if (reducedResults.length < this.props.maxResults) {
      return reducedResults;
    } else {
      return reducedResults.slice(0, this.props.maxResults);
    }
    // }
  }

  /**
   * Autosuggest calls this when a search result is selected
   * @event onChange
   * @param {event} event - The onChange event emitted
   * @param {{newValue: string}} newValue - the updated input value
   */
  onChange = (event, { newValue, method }) => {
    // console.log(newValue);
    this._fire('onChange', event, newValue, method);
    this._setState({ value: newValue });
  };

  // Autosuggest calls this every time you need to update suggestions.
  onSuggestionsFetchRequested = ({ value }) => {
    this._fire('onSuggestionsFetchRequested', value);
    this._setState({ isOpen: true });

    this._setState({
      suggestions: this.getSuggestions(value),
    });
  };

  // Autosuggest calls this every time you need to clear suggestions.
  onSuggestionsClearRequested = () => {
    this._fire('onSuggestionsClearRequested');
    this._setState({
      suggestions: [],
      isOpen: false,
    });
  };

  // maps to our custom onSelected event hook
  onSelected(event, suggestion) {
    this._fire('onSelected', event, suggestion);
    this._setState({ value: suggestion.suggestionValue });
    this.closeSearch();
  }

  renderSuggestion(suggestion, { query, isHighlighted }) {
    return (

        {suggestion.label}

    );
  }

  /**
   * Customizes the rendering of the input.
   * @param {object} inputProps - Props passed to the rendered input. https://github.com/moroshko/react-autosuggest#inputprops-required
   */
  renderInputComponent(inputProps) {
    const { value } = this.state;

    this._fire('onRenderInput', value);

    // console.log(value);

    const shouldShowClearButton = true;
    //
    //   this.props.noClearButton !== undefined &&
    //   this.props.noClearButton !== true &&
    //   value !== '';

    // const clearButtonText = this.props.clearButtonText
    //   ? this.props.clearButtonText
    //   : 'Clear Search Results';

    // const submitButtonText = this.props.submitButtonText
    //   ? this.props.submitButtonText
    //   : 'Submit';

    return (
      <div
        className={cx(`c-bolt-typeahead__input-wrapper`, {
          [`c-bolt-typeahead__input-wrapper--with-clear-button`]: shouldShowClearButton,
        })}>
        <input {...inputProps} />
        {/* {shouldShowClearButton && (
          <bolt-button
            dangerouslySetInnerHTML="foo"
            color="text"
            icon-only
            type="reset"
            className={cx(
              'c-bolt-typeahead__button',
              'c-bolt-typeahead__button--clear',
              {
                [`is-visible`]: value !== '',
              },
            )}
            onClick={() => {
              this.clearSearch();
            }}>
            {clearButtonText}
            <bolt-icon
              name="close-solid"
              slot="before"
              className={cx('c-bolt-typeahead__icon')}
              title={clearButtonText}
            />
          </bolt-button>
        )}
        <bolt-button
          dangerouslySetInnerHTML="foo"
          type="submit"
          color="text"
          icon-only
          onClick={() => {
            this.querySelector('form')
              ? this.querySelector('form').submit()
              : '';
          }}
          className={cx(
            'c-bolt-typeahead__button',
            'c-bolt-typeahead__button--submit',
          )}>
          {submitButtonText}
          <bolt-icon
            name="search"
            className={cx('c-bolt-typeahead__icon')}
            title={submitButtonText}
            slot="before"
          />
        </bolt-button> */}

    );
  }

  render() {
    const { suggestions } = this.state;

    const shouldShowClearButton =
      (this.props.noClearButton !== undefined &&
        this.props.noClearButton !== true &&
        this.state.value !== '') ||
      this.value !== '';

    const theme = {
      container: cx('c-bolt-typeahead'),
      containerOpen: cx('c-bolt-typeahead--open'),
      input: cx('c-bolt-typeahead__input', 'js-c-typeahead__input', {
        [`c-bolt-typeahead__input--with-clear-button`]: shouldShowClearButton,
      }),
      inputOpen: cx('c-bolt-typeahead__input--open'),
      inputFocused: cx('c-bolt-typeahead__input--focused'),
      suggestionsContainer: cx('c-bolt-typeahead__menu'),
      suggestionsContainerOpen: cx('is-open'),
      suggestionsList: cx('c-bolt-typeahead__results'),
      suggestion: cx('c-bolt-typeahead__result'),
      suggestionFirst: cx('c-bolt-typeahead__result--first'),
      suggestionHighlighted: cx('has-cursor'),
      sectionContainer: cx('c-bolt-typeahead__section-container'),
      sectionContainerFirst: cx('c-bolt-typeahead__section-container--first'),
      sectionTitle: cx('c-bolt-typeahead__section-title'),
    };

    // Autosuggest will pass through all these props to the input.
    const inputProps = {
      placeholder: this.props.placeholder,
      value: this.state.value || this.value,
      onChange: this.onChange,
      onInput: this.onInput,
    };

    return (
      <div
        class={cx('c-bolt-typeahead__wrapper')}
        style={`--typeahead-height: ${this.offsetHeight}px;`}>
        {this.addStyles([styles])}
        <Autosuggest
          theme={theme}
          suggestions={suggestions}
          inputProps={inputProps}
          getSuggestionValue={this.getSuggestionValue}
          onSuggestionSelected={this.onSelected}
          onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
          onSuggestionsClearRequested={this.onSuggestionsClearRequested}
          renderSuggestion={this.renderSuggestion}
          renderInputComponent={this.renderInputComponent}
        />

    );
  }

  rendered() {
    super.rendered && super.rendered();
    this._inputElement = this.renderRoot.querySelector(
      '.js-c-typeahead__input',
    );
  }
}

export { BoltAutosuggest };
<|endoftext|>


<commit_before>/*
Copyright 2018 New Vector Ltd

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import Modal from '../../../Modal';

import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from "../../../MatrixClientPeg";

export default class RoomUpgradeWarningBar extends React.Component {
    static propTypes = {
        room: PropTypes.object.isRequired,
        recommendation: PropTypes.object.isRequired,
    };

    componentDidMount() {
        const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
        this.setState({upgraded: tombstone && tombstone.getContent().replacement_room});

        MatrixClientPeg.get().on("RoomState.events", this._onStateEvents);
    }

    _onStateEvents = (event, state) => {
        if (!this.props.room || event.getRoomId() !== this.props.room.roomId) {
            return;
        }

        if (event.getType() !== "m.room.tombstone") return;

        const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
        this.setState({upgraded: tombstone && tombstone.getContent().replacement_room});
    };

    onUpgradeClick = () => {
        const RoomUpgradeDialog = sdk.getComponent('dialogs.RoomUpgradeDialog');
        Modal.createTrackedDialog('Upgrade Room Version', '', RoomUpgradeDialog, {room: this.props.room});
    };

    render() {
        const AccessibleButton = sdk.getComponent('elements.AccessibleButton');

        let doUpgradeWarnings = (

                        {_t(
                            "Upgrading this room will shut down the current instance of the room and create " +
                            "an upgraded room with the same name.",
                        )}

                        {_t(
                            "Warning: Upgrading a room will not automatically migrate room members " +
                            "to the new version of the room. We'll post a link to the new room in the old " +
                            "version of the room - room members will have to click this link to join the new room.",
                            {}, {
                                "b": (sub) => {sub},
                                "i": (sub) => {sub},
                            },
                        )}

                    <AccessibleButton onClick={this.onUpgradeClick}>
                        {_t("Upgrade this room to the recommended room version")}
                    </AccessibleButton>

        );

        if (this.state.upgraded) {
            doUpgradeWarnings = (

                        {_t("This room has already been upgraded.")}

            );
        }

        return (

                        {_t(
                            "This room is running room version <roomVersion />, which this homeserver has " +
                            "marked as unstable.",
                            {},
                            {
                                "roomVersion": () => {this.props.room.getVersion()},
                                "i": (sub) => {sub},
                            },
                        )}

                    {doUpgradeWarnings}

                        {_t("Only room administrators will see this warning")}

        );
    }
}
<commit_msg>fix uninitialised state and eventlistener leak<commit_after>/*
Copyright 2018-2020 New Vector Ltd

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import Modal from '../../../Modal';

import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from "../../../MatrixClientPeg";

export default class RoomUpgradeWarningBar extends React.Component {
    static propTypes = {
        room: PropTypes.object.isRequired,
        recommendation: PropTypes.object.isRequired,
    };

    constructor(props) {
        super(props);
        this.state = {};
    }

    componentDidMount() {
        const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
        this.setState({upgraded: tombstone && tombstone.getContent().replacement_room});

        MatrixClientPeg.get().on("RoomState.events", this._onStateEvents);
    }

    componentWillUnmount() {
        const cli = MatrixClientPeg.get();
        if (cli) {
            cli.removeListener("RoomState.events", this._onStateEvents);
        }
    }

    _onStateEvents = (event, state) => {
        if (!this.props.room || event.getRoomId() !== this.props.room.roomId) {
            return;
        }

        if (event.getType() !== "m.room.tombstone") return;

        const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
        this.setState({upgraded: tombstone && tombstone.getContent().replacement_room});
    };

    onUpgradeClick = () => {
        const RoomUpgradeDialog = sdk.getComponent('dialogs.RoomUpgradeDialog');
        Modal.createTrackedDialog('Upgrade Room Version', '', RoomUpgradeDialog, {room: this.props.room});
    };

    render() {
        const AccessibleButton = sdk.getComponent('elements.AccessibleButton');

        let doUpgradeWarnings = (

                        {_t(
                            "Upgrading this room will shut down the current instance of the room and create " +
                            "an upgraded room with the same name.",
                        )}

                        {_t(
                            "Warning: Upgrading a room will not automatically migrate room members " +
                            "to the new version of the room. We'll post a link to the new room in the old " +
                            "version of the room - room members will have to click this link to join the new room.",
                            {}, {
                                "b": (sub) => {sub},
                                "i": (sub) => {sub},
                            },
                        )}

                    <AccessibleButton onClick={this.onUpgradeClick}>
                        {_t("Upgrade this room to the recommended room version")}
                    </AccessibleButton>

        );

        if (this.state.upgraded) {
            doUpgradeWarnings = (

                        {_t("This room has already been upgraded.")}

            );
        }

        return (

                        {_t(
                            "This room is running room version <roomVersion />, which this homeserver has " +
                            "marked as unstable.",
                            {},
                            {
                                "roomVersion": () => {this.props.room.getVersion()},
                                "i": (sub) => {sub},
                            },
                        )}

                    {doUpgradeWarnings}

                        {_t("Only room administrators will see this warning")}

        );
    }
}
<|endoftext|>


<commit_before>define(function (require) {

    var zrUtil = require('zrender/core/util');
    var geoProps = [
        'x', 'y', 'x2', 'y2', 'width', 'height', 'map', 'roam', 'roamDetail', 'label', 'itemStyle'
    ];

    var geoCoordsMap = {};

    function createGeoFromMap(mapSeriesOpt) {
        var geoOpt = {};
        zrUtil.each(geoProps, function (propName) {
            if (mapSeriesOpt[propName] != null) {
                geoOpt[propName] = mapSeriesOpt[propName];
            }
        });
        return geoOpt;
    }
    return function (option) {
        // Save geoCoord
        var mapSeries = [];
        zrUtil.each(option.series, function (seriesOpt) {
            if (seriesOpt.type === 'map') {
                mapSeries.push(seriesOpt);
            }
            zrUtil.extend(geoCoordsMap, seriesOpt.geoCoord);
        });

        var newCreatedGeoOptMap = {};
        zrUtil.each(mapSeries, function (seriesOpt) {
            seriesOpt.map = seriesOpt.map || seriesOpt.mapType;
            // Put x, y, width, height, x2, y2 in the top level
            zrUtil.defaults(seriesOpt, seriesOpt.mapLocation);
            if (seriesOpt.markPoint) {
                var markPoint = seriesOpt.markPoint;
                // Convert name or geoCoord in markPoint to lng and lat
                // For example
                // { name: 'xxx', value: 10} Or
                // { geoCoord: [lng, lat], value: 10} to
                // { name: 'xxx', value: [lng, lat, 10]}
                markPoint.data = zrUtil.map(markPoint.data, function (dataOpt) {
                    if (!zrUtil.isArray(dataOpt.value)) {
                        var geoCoord;
                        if (dataOpt.geoCoord) {
                            geoCoord = dataOpt.geoCoord;
                        }
                        else if (dataOpt.name) {
                            geoCoord = geoCoordsMap[dataOpt.name];
                        }
                        var newValue = geoCoord ? [geoCoord[0], geoCoord[1]] : [NaN, NaN];
                        if (dataOpt.value != null) {
                            newValue.push(dataOpt.value);
                        }
                        dataOpt.value = newValue;
                    }
                    return dataOpt;
                });
                // Convert map series which only has markPoint without data to scatter series
                if (!(seriesOpt.data && seriesOpt.data.length)) {
                    // Use same geo if multiple map series has same map type
                    var geoOpt = newCreatedGeoOptMap[seriesOpt.map];
                    if (!geoOpt) {
                        geoOpt = newCreatedGeoOptMap[seriesOpt.map] = createGeoFromMap(seriesOpt);
                    }

                    if (!option.geo) {
                        option.geo = [];
                    }
                    var geoIndex = option.geo.length;
                    option.geo.push(geoOpt);

                    var scatterSeries = seriesOpt.markPoint;
                    scatterSeries.type = 'scatter';
                    scatterSeries.coordinateSystem = 'geo';
                    scatterSeries.geoIndex = geoIndex;
                    scatterSeries.name = seriesOpt.name;

                    option.series.splice(zrUtil.indexOf(option.series, seriesOpt), 1, scatterSeries);
                }
            }
        });
    };
});<commit_msg>tweak<commit_after>define(function (require) {

    var zrUtil = require('zrender/core/util');
    var geoProps = [
        'x', 'y', 'x2', 'y2', 'width', 'height', 'map', 'roam', 'roamDetail', 'label', 'itemStyle'
    ];

    var geoCoordsMap = {};

    function createGeoFromMap(mapSeriesOpt) {
        var geoOpt = {};
        zrUtil.each(geoProps, function (propName) {
            if (mapSeriesOpt[propName] != null) {
                geoOpt[propName] = mapSeriesOpt[propName];
            }
        });
        return geoOpt;
    }
    return function (option) {
        // Save geoCoord
        var mapSeries = [];
        zrUtil.each(option.series, function (seriesOpt) {
            if (seriesOpt.type === 'map') {
                mapSeries.push(seriesOpt);
            }
            zrUtil.extend(geoCoordsMap, seriesOpt.geoCoord);
        });

        var newCreatedGeoOptMap = {};
        zrUtil.each(mapSeries, function (seriesOpt) {
            seriesOpt.map = seriesOpt.map || seriesOpt.mapType;
            // Put x, y, width, height, x2, y2 in the top level
            zrUtil.defaults(seriesOpt, seriesOpt.mapLocation);
            if (seriesOpt.markPoint) {
                var markPoint = seriesOpt.markPoint;
                // Convert name or geoCoord in markPoint to lng and lat
                // For example
                // { name: 'xxx', value: 10} Or
                // { geoCoord: [lng, lat], value: 10} to
                // { name: 'xxx', value: [lng, lat, 10]}
                markPoint.data = zrUtil.map(markPoint.data, function (dataOpt) {
                    if (!zrUtil.isArray(dataOpt.value)) {
                        var geoCoord;
                        if (dataOpt.geoCoord) {
                            geoCoord = dataOpt.geoCoord;
                        }
                        else if (dataOpt.name) {
                            geoCoord = geoCoordsMap[dataOpt.name];
                        }
                        var newValue = geoCoord ? [geoCoord[0], geoCoord[1]] : [NaN, NaN];
                        if (dataOpt.value != null) {
                            newValue.push(dataOpt.value);
                        }
                        dataOpt.value = newValue;
                    }
                    return dataOpt;
                });
                // Convert map series which only has markPoint without data to scatter series
                if (!(seriesOpt.data && seriesOpt.data.length)) {

                    if (!option.geo) {
                        option.geo = [];
                    }
                    var geoIndex = option.geo.length;
                    // Use same geo if multiple map series has same map type
                    var geoOpt = newCreatedGeoOptMap[seriesOpt.map];
                    if (!geoOpt) {
                        geoOpt = newCreatedGeoOptMap[seriesOpt.map] = createGeoFromMap(seriesOpt);
                    option.geo.push(geoOpt);
                    }

                    var scatterSeries = seriesOpt.markPoint;
                    scatterSeries.type = 'scatter';
                    scatterSeries.coordinateSystem = 'geo';
                    scatterSeries.geoIndex = geoIndex;
                    scatterSeries.name = seriesOpt.name;

                    option.series.splice(zrUtil.indexOf(option.series, seriesOpt), 1, scatterSeries);
                }
            }
        });
    };
});<|endoftext|>


<commit_before>import { clone, filterChange, uuid } from 'pouchdb-utils';
import getDocs from './getDocs';
import Promise from 'pouchdb-promise';
import Checkpointer from 'pouchdb-checkpointer';
import backOff from './backoff';
import generateReplicationId from 'pouchdb-generate-replication-id';

function replicate(src, target, opts, returnValue, result) {
  var batches = [];               // list of batches to be processed
  var currentBatch;               // the batch currently being processed
  var pendingBatch = {
    seq: 0,
    changes: [],
    docs: []
  }; // next batch, not yet ready to be processed
  var writingCheckpoint = false;  // true while checkpoint is being written
  var changesCompleted = false;   // true when all changes received
  var replicationCompleted = false; // true when replication has completed
  var last_seq = 0;
  var continuous = opts.continuous || opts.live || false;
  var batch_size = opts.batch_size || 100;
  var batches_limit = opts.batches_limit || 10;
  var changesPending = false;     // true while src.changes is running
  var doc_ids = opts.doc_ids;
  var repId;
  var checkpointer;
  var allErrors = [];
  var changedDocs = [];
  // Like couchdb, every replication gets a unique session id
  var session = uuid();

  result = result || {
    ok: true,
    start_time: new Date(),
    docs_read: 0,
    docs_written: 0,
    doc_write_failures: 0,
    errors: []
  };

  var changesOpts = {};
  returnValue.ready(src, target);

  function initCheckpointer() {
    if (checkpointer) {
      return Promise.resolve();
    }
    return generateReplicationId(src, target, opts).then(function (res) {
      repId = res;
      checkpointer = new Checkpointer(src, target, repId, returnValue);
    });
  }

  function writeDocs() {
    changedDocs = [];

    if (currentBatch.docs.length === 0) {
      return;
    }
    var docs = currentBatch.docs;
    return target.bulkDocs({docs: docs, new_edits: false}).then(function (res) {
      if (returnValue.cancelled) {
        completeReplication();
        throw new Error('cancelled');
      }
      var errors = [];
      var errorsById = {};
      res.forEach(function (res) {
        if (res.error) {
          result.doc_write_failures++;
          errors.push(res);
          errorsById[res.id] = res;
        }
      });
      allErrors = allErrors.concat(errors);
      result.docs_written += currentBatch.docs.length - errors.length;
      var non403s = errors.filter(function (error) {
        return error.name !== 'unauthorized' && error.name !== 'forbidden';
      });

      docs.forEach(function (doc) {
        var error = errorsById[doc._id];
        if (error) {
          returnValue.emit('denied', clone(error));
        } else {
          changedDocs.push(doc);
        }
      });

      if (non403s.length > 0) {
        var error = new Error('bulkDocs error');
        error.other_errors = errors;
        abortReplication('target.bulkDocs failed to write docs', error);
        throw new Error('bulkWrite partial failure');
      }
    }, function (err) {
      result.doc_write_failures += docs.length;
      throw err;
    });
  }

  function finishBatch() {
    if (currentBatch.error) {
      throw new Error('There was a problem getting docs.');
    }
    result.last_seq = last_seq = currentBatch.seq;
    var outResult = clone(result);
    if (changedDocs.length) {
      outResult.docs = changedDocs;
      returnValue.emit('change', outResult);
    }
    writingCheckpoint = true;
    return checkpointer.writeCheckpoint(currentBatch.seq,
        session).then(function () {
      writingCheckpoint = false;
      if (returnValue.cancelled) {
        completeReplication();
        throw new Error('cancelled');
      }
      currentBatch = undefined;
      getChanges();
    }).catch(onCheckpointError);
  }

  function getDiffs() {
    var diff = {};
    currentBatch.changes.forEach(function (change) {
      // Couchbase Sync Gateway emits these, but we can ignore them
      /* istanbul ignore if */
      if (change.id === "_user/") {
        return;
      }
      diff[change.id] = change.changes.map(function (x) {
        return x.rev;
      });
    });
    return target.revsDiff(diff).then(function (diffs) {
      if (returnValue.cancelled) {
        completeReplication();
        throw new Error('cancelled');
      }
      // currentBatch.diffs elements are deleted as the documents are written
      currentBatch.diffs = diffs;
    });
  }

  function getBatchDocs() {
    return getDocs(src, target, currentBatch.diffs, returnValue).then(function (got) {
      currentBatch.error = !got.ok;
      got.docs.forEach(function (doc) {
        delete currentBatch.diffs[doc._id];
        result.docs_read++;
        currentBatch.docs.push(doc);
      });
    });
  }

  function startNextBatch() {
    if (returnValue.cancelled || currentBatch) {
      return;
    }
    if (batches.length === 0) {
      processPendingBatch(true);
      return;
    }
    currentBatch = batches.shift();
    getDiffs()
      .then(getBatchDocs)
      .then(writeDocs)
      .then(finishBatch)
      .then(startNextBatch)
      .catch(function (err) {
        abortReplication('batch processing terminated with error', err);
      });
  }

  function processPendingBatch(immediate) {
    if (pendingBatch.changes.length === 0) {
      if (batches.length === 0 && !currentBatch) {
        if ((continuous && changesOpts.live) || changesCompleted) {
          returnValue.state = 'pending';
          returnValue.emit('paused');
        }
        if (changesCompleted) {
          completeReplication();
        }
      }
      return;
    }
    if (
      immediate ||
      changesCompleted ||
      pendingBatch.changes.length >= batch_size
    ) {
      batches.push(pendingBatch);
      pendingBatch = {
        seq: 0,
        changes: [],
        docs: []
      };
      if (returnValue.state === 'pending' || returnValue.state === 'stopped') {
        returnValue.state = 'active';
        returnValue.emit('active');
      }
      startNextBatch();
    }
  }

  function abortReplication(reason, err) {
    if (replicationCompleted) {
      return;
    }
    if (!err.message) {
      err.message = reason;
    }
    result.ok = false;
    result.status = 'aborting';
    result.errors.push(err);
    allErrors = allErrors.concat(err);
    batches = [];
    pendingBatch = {
      seq: 0,
      changes: [],
      docs: []
    };
    completeReplication();
  }

  function completeReplication() {
    if (replicationCompleted) {
      return;
    }
    if (returnValue.cancelled) {
      result.status = 'cancelled';
      if (writingCheckpoint) {
        return;
      }
    }
    result.status = result.status || 'complete';
    result.end_time = new Date();
    result.last_seq = last_seq;
    replicationCompleted = true;
    var non403s = allErrors.filter(function (error) {
      return error.name !== 'unauthorized' && error.name !== 'forbidden';
    });
    if (non403s.length > 0) {
      var error = allErrors.pop();
      if (allErrors.length > 0) {
        error.other_errors = allErrors;
      }
      error.result = result;
      backOff(opts, returnValue, error, function () {
        replicate(src, target, opts, returnValue);
      });
    } else {
      result.errors = allErrors;
      returnValue.emit('complete', result);
      returnValue.removeAllListeners();
    }
  }

  function onChange(change) {
    if (returnValue.cancelled) {
      return completeReplication();
    }
    var filter = filterChange(opts)(change);
    if (!filter) {
      return;
    }
    pendingBatch.seq = change.seq;
    pendingBatch.changes.push(change);
    processPendingBatch(changesOpts.live);
  }

  function onChangesComplete(changes) {
    changesPending = false;
    if (returnValue.cancelled) {
      return completeReplication();
    }

    // if no results were returned then we're done,
    // else fetch more
    if (changes.results.length > 0) {
      changesOpts.since = changes.last_seq;
      getChanges();
      processPendingBatch(true);
    } else {

      var complete = function () {
        if (continuous) {
          changesOpts.live = true;
          getChanges();
        } else {
          changesCompleted = true;
        }
        processPendingBatch(true);
      };

      // update the checkpoint so we start from the right seq next time
      if (!currentBatch && changes.last_seq > last_seq) {
        writingCheckpoint = true;
        checkpointer.writeCheckpoint(changes.last_seq,
            session).then(function () {
          writingCheckpoint = false;
          result.last_seq = last_seq = changes.last_seq;
          complete();
        })
        .catch(onCheckpointError);
      } else {
        complete();
      }
    }
  }

  function onChangesError(err) {
    changesPending = false;
    /* istanbul ignore if */
    if (returnValue.cancelled) {
      return completeReplication();
    }
    abortReplication('changes rejected', err);
  }

  function getChanges() {
    if (!(
      !changesPending &&
      !changesCompleted &&
      batches.length < batches_limit
      )) {
      return;
    }
    changesPending = true;
    function abortChanges() {
      changes.cancel();
    }
    function removeListener() {
      returnValue.removeListener('cancel', abortChanges);
    }

    if (returnValue._changes) { // remove old changes() and listeners
      returnValue.removeListener('cancel', returnValue._abortChanges);
      returnValue._changes.cancel();
    }
    returnValue.once('cancel', abortChanges);

    var changes = src.changes(changesOpts)
      .on('change', onChange);
    changes.then(removeListener, removeListener);
    changes.then(onChangesComplete)
      .catch(onChangesError);

    if (opts.retry) {
      // save for later so we can cancel if necessary
      returnValue._changes = changes;
      returnValue._abortChanges = abortChanges;
    }
  }

  function startChanges() {
    initCheckpointer().then(function () {
      if (returnValue.cancelled) {
        completeReplication();
        return;
      }
      return checkpointer.getCheckpoint().then(function (checkpoint) {
        last_seq = checkpoint;
        changesOpts = {
          since: last_seq,
          limit: batch_size,
          batch_size: batch_size,
          style: 'all_docs',
          doc_ids: doc_ids,
          return_docs: true // required so we know when we're done
        };
        if (opts.filter) {
          if (typeof opts.filter !== 'string') {
            // required for the client-side filter in onChange
            changesOpts.include_docs = true;
          } else { // ddoc filter
            changesOpts.filter = opts.filter;
          }
        }
        if ('heartbeat' in opts) {
          changesOpts.heartbeat = opts.heartbeat;
        }
        if ('timeout' in opts) {
          changesOpts.timeout = opts.timeout;
        }
        if (opts.query_params) {
          changesOpts.query_params = opts.query_params;
        }
        if (opts.view) {
          changesOpts.view = opts.view;
        }
        getChanges();
      });
    }).catch(function (err) {
      abortReplication('getCheckpoint rejected with ', err);
    });
  }

  /* istanbul ignore next */
  function onCheckpointError(err) {
    writingCheckpoint = false;
    abortReplication('writeCheckpoint completed with error', err);
    throw err;
  }

  /* istanbul ignore if */
  if (returnValue.cancelled) { // cancelled immediately
    completeReplication();
    return;
  }

  if (!returnValue._addedListeners) {
    returnValue.once('cancel', completeReplication);

    if (typeof opts.complete === 'function') {
      returnValue.once('error', opts.complete);
      returnValue.once('complete', function (result) {
        opts.complete(null, result);
      });
    }
    returnValue._addedListeners = true;
  }

  if (typeof opts.since === 'undefined') {
    startChanges();
  } else {
    initCheckpointer().then(function () {
      writingCheckpoint = true;
      return checkpointer.writeCheckpoint(opts.since, session);
    }).then(function () {
      writingCheckpoint = false;
      /* istanbul ignore if */
      if (returnValue.cancelled) {
        completeReplication();
        return;
      }
      last_seq = opts.since;
      startChanges();
    }).catch(onCheckpointError);
  }
}

export default replicate;
<commit_msg>(#5196) - fix excessively long replication loop<commit_after>import { clone, filterChange, uuid } from 'pouchdb-utils';
import getDocs from './getDocs';
import Promise from 'pouchdb-promise';
import Checkpointer from 'pouchdb-checkpointer';
import backOff from './backoff';
import generateReplicationId from 'pouchdb-generate-replication-id';

function replicate(src, target, opts, returnValue, result) {
  var batches = [];               // list of batches to be processed
  var currentBatch;               // the batch currently being processed
  var pendingBatch = {
    seq: 0,
    changes: [],
    docs: []
  }; // next batch, not yet ready to be processed
  var writingCheckpoint = false;  // true while checkpoint is being written
  var changesCompleted = false;   // true when all changes received
  var replicationCompleted = false; // true when replication has completed
  var last_seq = 0;
  var continuous = opts.continuous || opts.live || false;
  var batch_size = opts.batch_size || 100;
  var batches_limit = opts.batches_limit || 10;
  var changesPending = false;     // true while src.changes is running
  var doc_ids = opts.doc_ids;
  var repId;
  var checkpointer;
  var allErrors = [];
  var changedDocs = [];
  // Like couchdb, every replication gets a unique session id
  var session = uuid();

  result = result || {
    ok: true,
    start_time: new Date(),
    docs_read: 0,
    docs_written: 0,
    doc_write_failures: 0,
    errors: []
  };

  var changesOpts = {};
  returnValue.ready(src, target);

  function initCheckpointer() {
    if (checkpointer) {
      return Promise.resolve();
    }
    return generateReplicationId(src, target, opts).then(function (res) {
      repId = res;
      checkpointer = new Checkpointer(src, target, repId, returnValue);
    });
  }

  function writeDocs() {
    changedDocs = [];

    if (currentBatch.docs.length === 0) {
      return;
    }
    var docs = currentBatch.docs;
    return target.bulkDocs({docs: docs, new_edits: false}).then(function (res) {
      if (returnValue.cancelled) {
        completeReplication();
        throw new Error('cancelled');
      }
      var errors = [];
      var errorsById = {};
      res.forEach(function (res) {
        if (res.error) {
          result.doc_write_failures++;
          errors.push(res);
          errorsById[res.id] = res;
        }
      });
      allErrors = allErrors.concat(errors);
      result.docs_written += currentBatch.docs.length - errors.length;
      var non403s = errors.filter(function (error) {
        return error.name !== 'unauthorized' && error.name !== 'forbidden';
      });

      docs.forEach(function (doc) {
        var error = errorsById[doc._id];
        if (error) {
          returnValue.emit('denied', clone(error));
        } else {
          changedDocs.push(doc);
        }
      });

      if (non403s.length > 0) {
        var error = new Error('bulkDocs error');
        error.other_errors = errors;
        abortReplication('target.bulkDocs failed to write docs', error);
        throw new Error('bulkWrite partial failure');
      }
    }, function (err) {
      result.doc_write_failures += docs.length;
      throw err;
    });
  }

  function finishBatch() {
    if (currentBatch.error) {
      throw new Error('There was a problem getting docs.');
    }
    result.last_seq = last_seq = currentBatch.seq;
    var outResult = clone(result);
    if (changedDocs.length) {
      outResult.docs = changedDocs;
      returnValue.emit('change', outResult);
    }
    writingCheckpoint = true;
    return checkpointer.writeCheckpoint(currentBatch.seq,
        session).then(function () {
      writingCheckpoint = false;
      if (returnValue.cancelled) {
        completeReplication();
        throw new Error('cancelled');
      }
      currentBatch = undefined;
      getChanges();
    }).catch(onCheckpointError);
  }

  function getDiffs() {
    var diff = {};
    currentBatch.changes.forEach(function (change) {
      // Couchbase Sync Gateway emits these, but we can ignore them
      /* istanbul ignore if */
      if (change.id === "_user/") {
        return;
      }
      diff[change.id] = change.changes.map(function (x) {
        return x.rev;
      });
    });
    return target.revsDiff(diff).then(function (diffs) {
      if (returnValue.cancelled) {
        completeReplication();
        throw new Error('cancelled');
      }
      // currentBatch.diffs elements are deleted as the documents are written
      currentBatch.diffs = diffs;
    });
  }

  function getBatchDocs() {
    return getDocs(src, target, currentBatch.diffs, returnValue).then(function (got) {
      currentBatch.error = !got.ok;
      got.docs.forEach(function (doc) {
        delete currentBatch.diffs[doc._id];
        result.docs_read++;
        currentBatch.docs.push(doc);
      });
    });
  }

  function startNextBatch() {
    if (returnValue.cancelled || currentBatch) {
      return;
    }
    if (batches.length === 0) {
      processPendingBatch(true);
      return;
    }
    currentBatch = batches.shift();
    getDiffs()
      .then(getBatchDocs)
      .then(writeDocs)
      .then(finishBatch)
      .then(startNextBatch)
      .catch(function (err) {
        abortReplication('batch processing terminated with error', err);
      });
  }

  function processPendingBatch(immediate) {
    if (pendingBatch.changes.length === 0) {
      if (batches.length === 0 && !currentBatch) {
        if ((continuous && changesOpts.live) || changesCompleted) {
          returnValue.state = 'pending';
          returnValue.emit('paused');
        }
        if (changesCompleted) {
          completeReplication();
        }
      }
      return;
    }
    if (
      immediate ||
      changesCompleted ||
      pendingBatch.changes.length >= batch_size
    ) {
      batches.push(pendingBatch);
      pendingBatch = {
        seq: 0,
        changes: [],
        docs: []
      };
      if (returnValue.state === 'pending' || returnValue.state === 'stopped') {
        returnValue.state = 'active';
        returnValue.emit('active');
      }
      startNextBatch();
    }
  }

  function abortReplication(reason, err) {
    if (replicationCompleted) {
      return;
    }
    if (!err.message) {
      err.message = reason;
    }
    result.ok = false;
    result.status = 'aborting';
    result.errors.push(err);
    allErrors = allErrors.concat(err);
    batches = [];
    pendingBatch = {
      seq: 0,
      changes: [],
      docs: []
    };
    completeReplication();
  }

  function completeReplication() {
    if (replicationCompleted) {
      return;
    }
    if (returnValue.cancelled) {
      result.status = 'cancelled';
      if (writingCheckpoint) {
        return;
      }
    }
    result.status = result.status || 'complete';
    result.end_time = new Date();
    result.last_seq = last_seq;
    replicationCompleted = true;
    var non403s = allErrors.filter(function (error) {
      return error.name !== 'unauthorized' && error.name !== 'forbidden';
    });
    if (non403s.length > 0) {
      var error = allErrors.pop();
      if (allErrors.length > 0) {
        error.other_errors = allErrors;
      }
      error.result = result;
      backOff(opts, returnValue, error, function () {
        replicate(src, target, opts, returnValue);
      });
    } else {
      result.errors = allErrors;
      returnValue.emit('complete', result);
      returnValue.removeAllListeners();
    }
  }

  function onChange(change) {
    if (returnValue.cancelled) {
      return completeReplication();
    }
    var filter = filterChange(opts)(change);
    if (!filter) {
      return;
    }
    pendingBatch.seq = change.seq;
    pendingBatch.changes.push(change);
    processPendingBatch(batches.length === 0 && changesOpts.live);
  }

  function onChangesComplete(changes) {
    changesPending = false;
    if (returnValue.cancelled) {
      return completeReplication();
    }

    // if no results were returned then we're done,
    // else fetch more
    if (changes.results.length > 0) {
      changesOpts.since = changes.last_seq;
      getChanges();
      processPendingBatch(true);
    } else {

      var complete = function () {
        if (continuous) {
          changesOpts.live = true;
          getChanges();
        } else {
          changesCompleted = true;
        }
        processPendingBatch(true);
      };

      // update the checkpoint so we start from the right seq next time
      if (!currentBatch && changes.last_seq > last_seq) {
        writingCheckpoint = true;
        checkpointer.writeCheckpoint(changes.last_seq,
            session).then(function () {
          writingCheckpoint = false;
          result.last_seq = last_seq = changes.last_seq;
          complete();
        })
        .catch(onCheckpointError);
      } else {
        complete();
      }
    }
  }

  function onChangesError(err) {
    changesPending = false;
    /* istanbul ignore if */
    if (returnValue.cancelled) {
      return completeReplication();
    }
    abortReplication('changes rejected', err);
  }

  function getChanges() {
    if (!(
      !changesPending &&
      !changesCompleted &&
      batches.length < batches_limit
      )) {
      return;
    }
    changesPending = true;
    function abortChanges() {
      changes.cancel();
    }
    function removeListener() {
      returnValue.removeListener('cancel', abortChanges);
    }

    if (returnValue._changes) { // remove old changes() and listeners
      returnValue.removeListener('cancel', returnValue._abortChanges);
      returnValue._changes.cancel();
    }
    returnValue.once('cancel', abortChanges);

    var changes = src.changes(changesOpts)
      .on('change', onChange);
    changes.then(removeListener, removeListener);
    changes.then(onChangesComplete)
      .catch(onChangesError);

    if (opts.retry) {
      // save for later so we can cancel if necessary
      returnValue._changes = changes;
      returnValue._abortChanges = abortChanges;
    }
  }

  function startChanges() {
    initCheckpointer().then(function () {
      if (returnValue.cancelled) {
        completeReplication();
        return;
      }
      return checkpointer.getCheckpoint().then(function (checkpoint) {
        last_seq = checkpoint;
        changesOpts = {
          since: last_seq,
          limit: batch_size,
          batch_size: batch_size,
          style: 'all_docs',
          doc_ids: doc_ids,
          return_docs: true // required so we know when we're done
        };
        if (opts.filter) {
          if (typeof opts.filter !== 'string') {
            // required for the client-side filter in onChange
            changesOpts.include_docs = true;
          } else { // ddoc filter
            changesOpts.filter = opts.filter;
          }
        }
        if ('heartbeat' in opts) {
          changesOpts.heartbeat = opts.heartbeat;
        }
        if ('timeout' in opts) {
          changesOpts.timeout = opts.timeout;
        }
        if (opts.query_params) {
          changesOpts.query_params = opts.query_params;
        }
        if (opts.view) {
          changesOpts.view = opts.view;
        }
        getChanges();
      });
    }).catch(function (err) {
      abortReplication('getCheckpoint rejected with ', err);
    });
  }

  /* istanbul ignore next */
  function onCheckpointError(err) {
    writingCheckpoint = false;
    abortReplication('writeCheckpoint completed with error', err);
    throw err;
  }

  /* istanbul ignore if */
  if (returnValue.cancelled) { // cancelled immediately
    completeReplication();
    return;
  }

  if (!returnValue._addedListeners) {
    returnValue.once('cancel', completeReplication);

    if (typeof opts.complete === 'function') {
      returnValue.once('error', opts.complete);
      returnValue.once('complete', function (result) {
        opts.complete(null, result);
      });
    }
    returnValue._addedListeners = true;
  }

  if (typeof opts.since === 'undefined') {
    startChanges();
  } else {
    initCheckpointer().then(function () {
      writingCheckpoint = true;
      return checkpointer.writeCheckpoint(opts.since, session);
    }).then(function () {
      writingCheckpoint = false;
      /* istanbul ignore if */
      if (returnValue.cancelled) {
        completeReplication();
        return;
      }
      last_seq = opts.since;
      startChanges();
    }).catch(onCheckpointError);
  }
}

export default replicate;
<|endoftext|>