app/models/Document.ts
Rama main · réplica de lectura del repositorio original.
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;
};
}