Ctrl K
ComunidadGuías…

app/models/Document.ts

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

Archivo original ↗Preguntar a KiBot
import { addDays, differenceInDays, differenceInSeconds } from "date-fns";
import i18n, { t } from "i18next";
import { capitalize, floor } from "es-toolkit/compat";
import {
  action,
  autorun,
  comparer,
  computed,
  observable,
  override,
  set,
} from "mobx";
import type {
  JSONObject,
  NavigationNode,
  ProsemirrorData,
} from "@shared/types";
import {
  type DocumentPreference,
  type DocumentPreferences,
  type ExportContentType,
  FileOperationFormat,
  NavigationNodeType,
  NotificationEventType,
} from "@shared/types";
import { DocumentPreferenceDefaults } from "@shared/constants";
import { ProsemirrorDataHelper } from "@shared/utils/ProsemirrorDataHelper";
import Storage from "@shared/utils/Storage";
import { isRTL } from "@shared/utils/rtl";
import type DocumentsStore from "~/stores/DocumentsStore";
import User from "~/models/User";
import type { Properties } from "~/types";
import { client } from "~/utils/ApiClient";
import Collection from "./Collection";
import type Notification from "./Notification";
import type View from "./View";
import ArchivableModel from "./base/ArchivableModel";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
import type { Searchable } from "./interfaces/Searchable";

type SaveOptions = JSONObject & {
  publish?: boolean;
  done?: boolean;
  autosave?: boolean;
};

export default class Document extends ArchivableModel implements Searchable {
  static modelName = "Document";

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

    this.embedsDisabled = Storage.get(`embedsDisabled-${this.id}`) ?? false;

    autorun(() => {
      Storage.set(
        `embedsDisabled-${this.id}`,
        this.embedsDisabled ? true : undefined
      );
    });
  }

  // Declared on Model; redeclared here only to give it a default.
  isSaving = false;

  @observable
  embedsDisabled = false;

  @observable
  lastViewedAt: string | undefined = undefined;

  store: DocumentsStore;

  @observable.shallow
  data: ProsemirrorData;

  /**
   * The original data source of the document, if imported.
   */
  sourceMetadata?: {
    /**
     * The type of importer that was used, if any. This can also be empty if an individual file was
     * imported through drag-and-drop, for example.
     */
    importType?: FileOperationFormat;
    /** The date this document was imported. */
    importedAt?: string;
    /** The name of the user the created the original source document. */
    createdByName?: string;
    /** The name of the file this document was imported from. */
    fileName?: string;
  };

  @computed
  get searchContent(): string {
    return this.title;
  }

  @computed
  get searchSuppressed(): boolean {
    return this.isDeleted || this.isArchived;
  }

  /**
   * The name of the original data source, if imported.
   */
  get sourceName() {
    if (!this.sourceMetadata?.importType) {
      return undefined;
    }

    switch (this.sourceMetadata.importType) {
      case FileOperationFormat.MarkdownZip:
        return "Markdown";
      case FileOperationFormat.JSON:
        return "JSON";
      case FileOperationFormat.Notion:
        return "Notion";
      default:
        return capitalize(this.sourceMetadata.importType);
    }
  }

  /**
   * The id of the collection that this document belongs to, if any.
   */
  @Field
  @observable
  collectionId?: string | null = undefined;
  /**
   * The collection that this document belongs to.
   */
  @Relation(() => Collection, { onDelete: "cascade" })
  collection?: Collection;

  /**
   * The owner of the personal collection that this document is in, if any.
   */
  @observable
  ownerId?: string | null;

  /**
   * The title of the document.
   */
  @Field
  @observable
  title: string;

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

  /** The likely language of the document, in ISO 639-1 format.  */
  language: string | undefined;

  /**
   * An icon (or) emoji to use as the document icon.
   */
  @Field
  @observable
  icon?: string | null = undefined;
  /**
   * The color to use for the document icon.
   */
  @Field
  @observable
  color?: string | null = undefined;
  /**
   * Whether the document layout is displayed full page width.
   */
  @Field
  @observable
  fullWidth: boolean;

  /**
   * Display preferences for the document.
   */
  @Field
  @observable
  preferences: DocumentPreferences | null;

  /**
   * Whether team members can see who has viewed this document.
   */
  @observable
  insightsEnabled: boolean;

  /**
   * A reference to the template that this document was created from.
   */
  @Field
  @observable
  templateId: string | undefined = undefined;

  /**
   * The id of the parent document that this is a child of, if any.
   */
  @Field
  @observable
  parentDocumentId?: string = undefined;
  /**
   * Parent document that this is a child of, if any.
   */
  @Relation(() => Document, { onArchive: "cascade", onDelete: "cascade" })
  parentDocument?: Document;

  /**
   * The ids of users that have edited this document.
   */
  @observable
  collaboratorIds: string[] | undefined = undefined;

  @Relation(() => User)
  createdBy: User | undefined;

  @Relation(() => User)
  updatedBy: User | undefined;

  /**
   * The user that deleted this document, only set while the document is in the
   * trash.
   */
  @Relation(() => User)
  deletedBy: User | undefined;

  @observable
  publishedAt: string | undefined = undefined;

  @observable
  popularityScore: number;

  /** The path to the document. The server makes the slug from the title. */
  @observable
  path: string;

  @observable
  urlId: string;

  @observable
  tasks: {
    completed: number;
    total: number;
  };

  @observable
  revision: number;

  /**
   * Whether this document is contained in a collection that has been deleted.
   */
  @observable
  isCollectionDeleted: boolean;

  /**
   * Array of backlink document IDs for publicly shared documents.
   * Only populated when viewing through a share link.
   */
  @observable
  backlinkIds?: string[] = undefined;
  /**
   * Returns the notifications associated with this document.
   */
  @computed
  get notifications(): Notification[] {
    return this.store.rootStore.notifications.filter(
      (notification: Notification) => notification.documentId === this.id
    );
  }

  /**
   * Returns the unread notifications associated with this document.
   */
  @computed
  get unreadNotifications(): Notification[] {
    return this.notifications.filter((notification) => !notification.viewedAt);
  }

  /**
   * Returns the direction of the document text, either "rtl" or "ltr"
   */
  @computed
  get dir(): "rtl" | "ltr" {
    return this.rtl ? "rtl" : "ltr";
  }

  /**
   * Returns true if the document text is right-to-left
   */
  @computed
  get rtl() {
    return isRTL(this.title);
  }

  /**
   * Returns the initial character of the document title in uppercase
   */
  @computed
  get initial(): string {
    return (this.title?.charAt(0) ?? "?").toUpperCase();
  }

  @computed
  get noun(): string {
    return t("document");
  }

  @computed
  get modifiedSinceViewed(): boolean {
    return !!this.lastViewedAt && this.lastViewedAt < this.updatedAt;
  }

  @computed
  get isBadgedNew(): boolean {
    return (
      !this.lastViewedAt &&
      differenceInDays(new Date(), new Date(this.createdAt)) < 14
    );
  }

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

  @computed
  get collaborators(): User[] {
    return (this.collaboratorIds ?? [])
      .map((id) => this.store.rootStore.users.get(id))
      .filter(Boolean) as User[];
  }

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

  /**
   * Returns whether the document is currently publicly shared, taking into account
   * the document's and team's sharing settings.
   *
   * @returns True if the document is publicly shared, false otherwise.
   */
  get isPubliclyShared(): boolean {
    const { shares, auth } = this.store.rootStore;
    const share = shares.getByDocumentId(this.id);
    const sharedParent = shares.getByDocumentParents(this);

    return !!(
      auth.team?.sharing !== false &&
      this.collection?.sharing !== false &&
      (share?.isActive || (sharedParent && !this.isDraft))
    );
  }

  /**
   * Returns the documents that link to this document.
   * For publicly shared documents, uses the backlinkIds provided by the server.
   * For authenticated users, uses the store's backlink data.
   *
   * @returns documents that link to this document.
   */
  @computed
  get backlinks(): Document[] {
    if (this.backlinkIds) {
      return this.backlinkIds
        .map((id) => this.store.get(id))
        .filter(Boolean) as Document[];
    }
    return this.store.getBacklinkedDocuments(this.id);
  }

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

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

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

  @computed
  get isDraft(): boolean {
    return !this.publishedAt;
  }

  /**
   * Whether the document has no content. Returns false if the content is not
   * loaded.
   */
  @computed
  get isEmpty(): boolean {
    return !!this.data && ProsemirrorDataHelper.isEmpty(this.data);
  }

  get hasEmptyTitle(): boolean {
    return this.title === "";
  }

  @computed
  get permanentlyDeletedAt(): string | undefined {
    if (!this.deletedAt) {
      return undefined;
    }

    return addDays(new Date(this.deletedAt), 30).toString();
  }

  @computed
  get isPersistedOnce(): boolean {
    return this.createdAt === this.updatedAt;
  }

  /**
   * Whether the document was created moments ago, and so cannot yet have views, comments, shares,
   * or backlinks of its own.
   *
   * @returns true if the document was created within the last ten seconds.
   */
  get isJustCreated(): boolean {
    return differenceInSeconds(new Date(), new Date(this.createdAt)) < 10;
  }

  @computed
  get isFromTemplate(): boolean {
    return !!this.templateId;
  }

  @computed
  get isTasks(): boolean {
    return !!this.tasks?.total;
  }

  @computed
  get tasksPercentage(): number {
    if (!this.isTasks) {
      return 0;
    }

    return floor((this.tasks.completed / this.tasks.total) * 100);
  }

  /**
   * Returns the path to the document, using the collection structure if available.
   * otherwise if we're viewing a shared document we can iterate up the parentDocument tree.
   *
   * @returns path to the document
   */
  @computed
  get pathTo() {
    if (this.collection?.documents) {
      return this.collection.pathToDocument(this.id);
    }

    // find root parent document we have access to
    const path: Document[] = [this];

    while (path[0]?.parentDocument) {
      path.unshift(path[0].parentDocument);
    }

    return path.map((item) => item.asNavigationNode);
  }

  get displayTitle(): string {
    return this.title || i18n.t("Untitled");
  }

  @action
  updateTasks(total: number, completed: number) {
    if (total !== this.tasks.total || completed !== this.tasks.completed) {
      this.tasks = { total, completed };
    }
  }

  /**
   * Get the value for a specific display preference key, or the default if
   * none is set.
   *
   * @param key The DocumentPreference key to retrieve
   * @returns The value
   */
  getPreference<K extends DocumentPreference>(key: K): DocumentPreferences[K] {
    return this.preferences?.[key] ?? DocumentPreferenceDefaults[key];
  }

  /**
   * Set the value for a specific display preference key.
   *
   * @param key The DocumentPreference key to set
   * @param value The value to set
   */
  @action
  setPreference<K extends DocumentPreference>(
    key: K,
    value: NonNullable<DocumentPreferences[K]>
  ) {
    this.preferences = {
      ...this.preferences,
      [key]: value,
    };
  }

  /**
   * Archives the document with an optional reason.
   *
   * @param options the archive options.
   * @returns a promise that resolves when the document is archived.
   */
  archive = (options?: { reason?: string }) =>
    this.store.archive(this, options);

  restore = (options?: { revisionId?: string; collectionId?: string }) =>
    this.store.restore(this, options);

  unpublish = (
    options: { detach?: boolean } = {
      detach: false,
    }
  ) => this.store.unpublish(this, options);

  @action
  enableEmbeds = () => {
    this.embedsDisabled = false;
  };

  @action
  disableEmbeds = () => {
    this.embedsDisabled = true;
  };

  @action
  pin = (collectionId?: string | null) =>
    this.store.rootStore.pins.create({
      documentId: this.id,
      ...(collectionId ? { collectionId } : {}),
    });

  @action
  unpin = (collectionId?: string) => {
    const pin = this.store.rootStore.pins.orderedData.find(
      (pin) =>
        pin.documentId === this.id &&
        (pin.collectionId === collectionId ||
          (!collectionId && !pin.collectionId))
    );

    return pin?.delete();
  };

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

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

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

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

  @action
  view = () => {
    // we don't record views for documents in the trash
    if (this.isDeleted) {
      return;
    }

    // Mark associated unread notifications as read when the document is viewed
    this.store.rootStore.notifications
      .filter(
        (notification: Notification) =>
          !notification.viewedAt &&
          notification.documentId === this.id &&
          [
            NotificationEventType.AddUserToDocument,
            NotificationEventType.UpdateDocument,
            NotificationEventType.PublishDocument,
          ].includes(notification.event)
      )
      .forEach((notification) => notification.markAsRead());

    this.lastViewedAt = new Date().toString();

    return this.store.rootStore.views.create({
      documentId: this.id,
    });
  };

  @action
  updateLastViewed = (view: View) => {
    this.lastViewedAt = view.lastViewedAt;
  };

  @action
  save = async (
    fields?: Properties<typeof this>,
    options?: SaveOptions
  ): Promise<Document> => {
    const params = fields ?? this.toAPI();
    this.isSaving = true;

    try {
      const model = await this.store.save(
        { ...params, ...fields, id: this.id },
        options
      );

      // if saving is successful set the new values on the model itself
      set(this, Object.assign({}, params, model));

      this.persistedAttributes = this.toAPI();

      return model;
    } finally {
      this.isSaving = false;
    }
  };

  move = (options: {
    collectionId?: string | null;
    parentDocumentId?: string;
  }) => this.store.move({ documentId: this.id, ...options });

  duplicate = (options?: {
    title?: string;
    publish?: boolean;
    recursive?: boolean;
    collectionId?: string | null;
    parentDocumentId?: string;
  }) => this.store.duplicate(this, options);

  /**
   * Returns the first blocks of the document, useful for displaying a preview.
   *
   * @param blocks The number of blocks to return, defaults to 4.
   * @returns A new ProseMirror document.
   */
  getSummary = (blocks = 4) => ({
    ...this.data,
    content: this.data.content?.slice(0, blocks),
  });

  @computed
  get pinned(): boolean {
    return !!this.store.rootStore.pins.orderedData.find(
      (pin) =>
        pin.documentId === this.id && pin.collectionId === this.collectionId
    );
  }

  @computed
  get pinnedToHome(): boolean {
    return !!this.store.rootStore.pins.orderedData.find(
      (pin) => pin.documentId === this.id && !pin.collectionId
    );
  }

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

  @computed
  get childDocuments() {
    return this.store.orderedData.filter(
      (doc) =>
        doc.parentDocumentId === this.id && this.isActive === doc.isActive
    );
  }

  @computed({ equals: comparer.structural })
  get asNavigationNode(): NavigationNode {
    return {
      type: NavigationNodeType.Document,
      id: this.id,
      title: this.title,
      color: this.color ?? undefined,
      icon: this.icon ?? undefined,
      children: this.childDocuments.map((doc) => doc.asNavigationNode),
      url: this.path,
      isDraft: this.isDraft,
    };
  }

  /**
   * Returns all children of the document.
   * This is determined by the collection structure, or the user/group memberships in case it's a shared document.
   *
   * @returns An array of NavigationNode objects.
   */
  @computed
  get children(): NavigationNode[] {
    const { userMemberships, groupMemberships } = this.store.rootStore;
    const collection = this.collection;

    const membership =
      userMemberships.getByDocumentId(this.id) ??
      groupMemberships.getByDocumentId(this.id);

    return (
      collection?.getChildrenForDocument(this.id) ??
      membership?.getChildrenForDocument(this.id) ??
      []
    );
  }

  /**
   * Download the document in the given format.
   *
   * Nested documents are included by default when the document has children, in
   * which case the file is prepared in the background and the user is notified
   * with a toast once it is ready.
   *
   * @param options.contentType The format to export the document in.
   * @param options.includeChildDocuments Whether to include nested documents.
   * @returns the API response.
   */
  download = async ({
    contentType,
    includeChildDocuments = this.children.length > 0,
  }: {
    contentType: ExportContentType;
    includeChildDocuments?: boolean;
  }) => {
    const response = await client.post(
      `/documents.export`,
      {
        id: this.id,
        includeChildDocuments,
      },
      {
        ...(includeChildDocuments ? {} : { download: true }),
        headers: {
          accept: contentType,
        },
      }
    );

    const fileOperation = response?.data?.fileOperation;
    if (fileOperation) {
      this.store.rootStore.ui.showExportToast(fileOperation.id);
    }

    return response;
  };
}