Ctrl K
ComunidadGuías…

app/models/Collection.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import i18n from "i18next";
import invariant from "invariant";
import {
  action,
  comparer,
  computed,
  observable,
  override,
  runInAction,
} from "mobx";
import {
  type CollectionPermission,
  type FileOperationFormat,
  type NavigationNode,
  NavigationNodeType,
  type ProsemirrorData,
} from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections";
import type CollectionsStore from "~/stores/CollectionsStore";
import type Document from "~/models/Document";
import ParanoidModel from "~/models/base/ParanoidModel";
import { client } from "~/utils/ApiClient";
import type User from "./User";
import Field from "./decorators/Field";
import { AfterChange } from "./decorators/Lifecycle";

export default class Collection extends ParanoidModel {
  static modelName = "Collection";

  constructor(fields: Record<string, unknown>, store: CollectionsStore) {
    super(fields, store);
    this.initialize(fields);
  }

  store: CollectionsStore;

  /** The name of the collection. */
  @Field
  @observable
  name: string;

  /** The reason this collection is archived. */
  @observable
  deprecatedReason: string | null = null;

  /** Collection description in Prosemirror format. */
  @Field
  @observable.shallow
  data: ProsemirrorData;

  /** An icon (or) emoji to use as the collection icon. */
  @Field
  @observable
  icon: string;

  /** The color to use for the collection icon and other highlights. */
  @Field
  @observable
  color?: string | null = undefined;
  /** The default permission for workspace users. */
  @Field
  @observable
  permission?: CollectionPermission = undefined;

  /** The user that owns this collection, if it is a personal collection. */
  @observable
  ownerId: string | null;
  /**
   * Whether public sharing is enabled for the collection. Note this can also be disabled at the
   * workspace level.
   */
  @Field
  @observable
  sharing: boolean;

  /** The sort index for the collection. */
  @Field
  @observable
  index: string;

  /** The sort field and direction for documents in the collection. */
  @Field
  @observable
  sort: {
    field: string;
    direction: "asc" | "desc";
  };

  /** The minimum permission level required to manage templates in this collection. */
  @Field
  @observable
  templateManagement: CollectionPermission;

  /**
   * Whether commenting is enabled for the collection.
   */
  @Field
  @observable
  commenting?: boolean | null = undefined;
  /** The child documents of the collection. */
  @observable
  documents?: NavigationNode[] = undefined;
  /** @deprecated Use path instead. */
  @observable
  url: string;

  /** The ID that appears in the collection slug. */
  @observable
  urlId: string;

  /**
   * The date and time the collection was archived.
   */
  @observable
  archivedAt: string;

  /**
   * User who archived the collection.
   */
  @observable
  archivedBy?: User = undefined;
  @computed
  get searchContent(): string {
    return this.displayName;
  }

  /**
   * The name to show for the collection. A personal collection is always shown
   * with a translated name, whatever name is stored.
   *
   * @returns the name to display.
   */
  @computed
  get displayName(): string {
    return this.isPersonal ? i18n.t("Personal") : this.name;
  }

  /** Returns whether the collection is empty, or undefined if not loaded. */
  @computed
  get isEmpty(): boolean | undefined {
    if (!this.documents) {
      return undefined;
    }

    return (
      this.documents.length === 0 &&
      this.store.rootStore.documents.inCollection(this.id).length === 0
    );
  }

  /**
   * Convenience method to return if a collection is considered private.
   * This means that a membership is required to view it rather than just being
   * a workspace member.
   *
   * @returns boolean
   */
  get isPrivate(): boolean {
    return this.permission === null;
  }

  /**
   * Whether this collection is the personal collection of a user.
   *
   * @returns boolean
   */
  get isPersonal(): boolean {
    return !!this.ownerId;
  }

  @computed
  get isStarred(): boolean {
    return !!this.store.rootStore.stars.orderedData.find(
      (star) => star.collectionId === this.id
    );
  }

  /**
   * Returns whether there is a subscription for this collection in the store.
   *
   * @returns True if there is a subscription, false otherwise.
   */
  @computed
  get isSubscribed(): boolean {
    return !!this.store.rootStore.subscriptions.getByCollectionId(this.id);
  }

  @computed
  get isManualSort(): boolean {
    return this.sort.field === "index";
  }

  @computed({ equals: comparer.structural })
  get sortedDocuments(): NavigationNode[] | undefined {
    if (!this.documents) {
      return undefined;
    }
    return sortNavigationNodes(this.documents, this.sort);
  }

  /**
   * Returns a lookup from document id to child documents.
   *
   * @returns a map of document id to child document nodes.
   */
  @computed({ keepAlive: true })
  get childrenByDocumentId(): Map<string, NavigationNode[]> {
    const childrenByDocumentId = new Map<string, NavigationNode[]>();

    const travelNodes = (nodes: NavigationNode[]) => {
      for (const node of nodes) {
        childrenByDocumentId.set(node.id, node.children);
        travelNodes(node.children);
      }
    };

    if (this.sortedDocuments) {
      travelNodes(this.sortedDocuments);
    }

    return childrenByDocumentId;
  }

  /** The initial letter of the collection name as a string. */
  @computed
  get initial() {
    return (this.displayName?.charAt(0) ?? "?").toUpperCase();
  }

  @computed
  get path() {
    return this.url;
  }

  /**
   * Returns users that have been individually given access to the collection.
   *
   * @returns A list of users that have been given access to the collection.
   */
  @computed
  get members(): User[] {
    return this.store.rootStore.memberships.orderedData
      .filter((m) => m.collectionId === this.id)
      .map((m) => m.user)
      .filter(Boolean);
  }

  @computed
  get isArchived() {
    return !!this.archivedAt;
  }

  @override
  get isDeleted() {
    return !!this.deletedAt;
  }

  @computed
  get isActive() {
    return !this.isArchived && !this.isDeleted;
  }

  @computed
  get hasDocuments() {
    return !!this.documents?.length;
  }

  fetchDocuments = async (options?: { force: boolean }) => {
    if (this.isFetching) {
      return;
    }
    if (this.documents && options?.force !== true) {
      return;
    }

    try {
      this.isFetching = true;
      const res = await client.post("/collections.documents", {
        id: this.id,
      });
      invariant(res?.data, "Data should be available");

      runInAction(() => {
        this.documents = res.data;
      });
    } finally {
      this.isFetching = false;
    }
  };

  /**
   * Updates the document identified by the given id in the collection in memory.
   * Does not update the document in the database.
   *
   * @param document The document properties stored in the collection
   */
  @action
  updateDocument(
    document: Pick<Document, "id" | "title" | "path" | "color" | "icon">
  ) {
    if (!this.documents) {
      return;
    }

    const travelNodes = (nodes: NavigationNode[]) =>
      nodes.forEach((node) => {
        if (node.id === document.id) {
          node.color = document.color ?? undefined;
          node.icon = document.icon ?? undefined;
          node.title = document.title;
          node.url = document.path;
        } else {
          travelNodes(node.children);
        }
      });

    travelNodes(this.documents);
  }

  /**
   * Removes the document identified by the given id from the collection in
   * memory. Does not remove the document from the database.
   *
   * @param documentId The id of the document to remove.
   */
  @action
  removeDocument(documentId: string) {
    if (!this.documents) {
      return;
    }

    this.documents = this.documents.filter(function f(node): boolean {
      if (node.id === documentId) {
        return false;
      }

      if (node.children) {
        node.children = node.children.filter(f);
      }

      return true;
    });
  }

  /**
   * Adds the document identified by the given id to the collection in
   * memory, if it is not already present. Does not add the document to the
   * database or store.
   *
   * @param document The document to add.
   * @param parentDocumentId The id of the document to add the new document to.
   */
  @action
  addDocument(document: Document, parentDocumentId?: string) {
    if (!this.documents || this.childrenByDocumentId.has(document.id)) {
      return;
    }

    if (!parentDocumentId) {
      this.documents.unshift(document.asNavigationNode);
      return;
    }

    const travelNodes = (nodes: NavigationNode[]) =>
      nodes.forEach((node) => {
        if (node.id === parentDocumentId) {
          node.children = [document.asNavigationNode, ...(node.children ?? [])];
        } else {
          travelNodes(node.children);
        }
      });

    travelNodes(this.documents);
  }

  @action
  updateIndex(index: string) {
    this.index = index;
  }

  getChildrenForDocument(documentId: string) {
    return this.childrenByDocumentId.get(documentId) ?? [];
  }

  @computed
  get asNavigationNode(): NavigationNode {
    return {
      type: NavigationNodeType.Collection,
      id: this.id,
      title: this.name,
      color: this.color ?? undefined,
      icon: this.icon ?? undefined,
      children: this.documents ?? [],
      url: this.url,
    };
  }

  pathToDocument(documentId: string) {
    let path: NavigationNode[] | undefined = [];
    const document = this.store.rootStore.documents.get(documentId);
    if (!document) {
      return path;
    }

    const travelNodes = (
      nodes: NavigationNode[],
      previousPath: NavigationNode[]
    ) => {
      nodes.forEach((node) => {
        const newPath = [...previousPath, node];

        if (node.id === documentId) {
          path = newPath;
          return;
        }

        if (
          document.parentDocumentId &&
          node.id === document.parentDocumentId
        ) {
          path = [...newPath, document.asNavigationNode];
          return;
        }

        return travelNodes(node.children, newPath);
      });
    };

    if (this.documents) {
      travelNodes(this.documents, path);
    }

    return path;
  }

  @action
  star = async (index?: string) => this.store.star(this, index);

  @action
  unstar = async () => this.store.unstar(this);

  /**
   * Subscribes the current user to this collection.
   *
   * @returns A promise that resolves when the subscription is created.
   */
  @action
  subscribe = () => this.store.subscribe(this);

  /**
   * Unsubscribes the current user from this collection.
   *
   * @returns A promise that resolves when the subscription is destroyed.
   */
  @action
  unsubscribe = () => this.store.unsubscribe(this);

  archive = () => this.store.archive(this);

  restore = () => this.store.restore(this);

  /**
   * Duplicates the collection and the published documents within it.
   *
   * @returns A promise that resolves to the duplicated collection.
   */
  duplicate = (options?: { name?: string }) =>
    this.store.duplicate(this, options);

  export = (format: FileOperationFormat, includeAttachments: boolean) =>
    client.post("/collections.export", {
      id: this.id,
      format,
      includeAttachments,
    });

  // hooks

  @AfterChange
  static removePolicies(
    model: Collection,
    previousAttributes: Partial<Collection>
  ) {
    if (
      previousAttributes &&
      (model.sharing !== previousAttributes?.sharing ||
        model.permission !== previousAttributes?.permission)
    ) {
      const { documents, policies } = model.store.rootStore;

      documents.inCollection(model.id).forEach((document) => {
        policies.remove(document.id);
      });
    }
  }

  private isFetching = false;
}