mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
* feat: Add TextPack (.textpack) import support Adds a single-document importer for TextPack, the zipped variant of TextBundle used by Bear, Ulysses, iA Writer and others. Bare .textbundle directories are not supported, as a directory cannot be delivered through a browser file input. The bundle's text entry may use any extension, per the spec, with info.json's type deciding whether it can be read as markdown. Assets are inlined as data URIs for the existing attachment pipeline to pick up, bounded by the attachment size limit and a memory ceiling, and only for media types markdown-it accepts as a link destination. Also fixes an existing bug where a file embedded in an HTML or email import as a data URI was stored in the document as base64 rather than being uploaded as an attachment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Refactor to individual converters * refactor --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
755 lines
20 KiB
TypeScript
755 lines
20 KiB
TypeScript
import invariant from "invariant";
|
|
import { compact, filter, omitBy, orderBy } from "es-toolkit/compat";
|
|
import { observable, action, computed, runInAction } from "mobx";
|
|
import type { DirectionFilter, SortFilter } from "@shared/types";
|
|
import {
|
|
AttachmentPreset,
|
|
SubscriptionType,
|
|
type DateFilter,
|
|
type StatusFilter,
|
|
} from "@shared/types";
|
|
import { subtractDate } from "@shared/utils/date";
|
|
import { bytesToHumanReadable } from "@shared/utils/files";
|
|
import naturalSort from "@shared/utils/naturalSort";
|
|
import type RootStore from "~/stores/RootStore";
|
|
import Store from "~/stores/base/Store";
|
|
import Document from "~/models/Document";
|
|
import env from "~/env";
|
|
import type {
|
|
FetchOptions,
|
|
PaginationParams,
|
|
PartialExcept,
|
|
SearchResult,
|
|
} from "~/types";
|
|
import { client } from "~/utils/ApiClient";
|
|
import { extname, uploadFile } from "~/utils/files";
|
|
|
|
type FetchPageParams = PaginationParams & {
|
|
template?: boolean;
|
|
collectionId?: string;
|
|
};
|
|
|
|
export type SearchParams = {
|
|
query?: string;
|
|
offset?: number;
|
|
limit?: number;
|
|
dateFilter?: DateFilter;
|
|
statusFilter?: StatusFilter[];
|
|
collectionId?: string;
|
|
userId?: string;
|
|
shareId?: string;
|
|
sort?: SortFilter;
|
|
direction?: DirectionFilter;
|
|
};
|
|
|
|
type ImportOptions = {
|
|
publish?: boolean;
|
|
};
|
|
|
|
export default class DocumentsStore extends Store<Document> {
|
|
@observable
|
|
backlinks: Map<string, string[]> = new Map();
|
|
|
|
@observable
|
|
similar: Map<string, string[]> = new Map();
|
|
|
|
@observable
|
|
movingDocumentId: string | null | undefined;
|
|
|
|
importFileTypes: string[] = [
|
|
".md",
|
|
".markdown",
|
|
".doc",
|
|
".docx",
|
|
".txt",
|
|
".htm",
|
|
".html",
|
|
".csv",
|
|
".tsv",
|
|
".mhtml",
|
|
".mht",
|
|
".eml",
|
|
".textpack",
|
|
"text/csv",
|
|
"text/tab-separated-values",
|
|
"text/markdown",
|
|
"text/plain",
|
|
"text/html",
|
|
"application/msword",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"message/rfc822",
|
|
"multipart/related",
|
|
"application/x-mimearchive",
|
|
];
|
|
|
|
constructor(rootStore: RootStore) {
|
|
super(rootStore, Document);
|
|
}
|
|
|
|
@computed
|
|
get importFileTypesString(): string {
|
|
return this.importFileTypes.join(",");
|
|
}
|
|
|
|
@computed
|
|
get all(): Document[] {
|
|
return filter(this.orderedData, (d) => !d.archivedAt && !d.deletedAt);
|
|
}
|
|
|
|
@computed
|
|
get recentlyViewed(): Document[] {
|
|
return orderBy(
|
|
this.all.filter((d) => d.lastViewedAt),
|
|
"lastViewedAt",
|
|
"desc"
|
|
);
|
|
}
|
|
|
|
@computed
|
|
get recentlyUpdated(): Document[] {
|
|
return orderBy(this.all, "updatedAt", "desc");
|
|
}
|
|
|
|
@computed
|
|
get popular(): Document[] {
|
|
return orderBy(this.all, "popularityScore", "desc");
|
|
}
|
|
|
|
createdByUser(userId: string): Document[] {
|
|
return orderBy(
|
|
filter(this.all, (d) => d.createdBy?.id === userId),
|
|
"updatedAt",
|
|
"desc"
|
|
);
|
|
}
|
|
|
|
inCollection(collectionId: string): Document[] {
|
|
return filter(
|
|
this.all,
|
|
(document) => document.collectionId === collectionId
|
|
);
|
|
}
|
|
|
|
archivedInCollection(
|
|
collectionId: string,
|
|
options?: { archivedAt: string }
|
|
): Document[] {
|
|
const filterCond = (document: Document) =>
|
|
options
|
|
? document.collectionId === collectionId &&
|
|
document.isArchived &&
|
|
document.archivedAt === options.archivedAt &&
|
|
!document.isDeleted
|
|
: document.collectionId === collectionId &&
|
|
document.isArchived &&
|
|
!document.isDeleted;
|
|
|
|
return filter(this.orderedData, filterCond);
|
|
}
|
|
|
|
unarchivedInCollection(collectionId: string): Document[] {
|
|
return filter(
|
|
this.orderedData,
|
|
(document) =>
|
|
document.collectionId === collectionId &&
|
|
!document.isArchived &&
|
|
!document.isDeleted
|
|
);
|
|
}
|
|
|
|
publishedInCollection(collectionId: string): Document[] {
|
|
return filter(
|
|
this.all,
|
|
(document) =>
|
|
document.collectionId === collectionId && !!document.publishedAt
|
|
);
|
|
}
|
|
|
|
rootInCollection(collectionId: string): Document[] {
|
|
const collection = this.rootStore.collections.get(collectionId);
|
|
|
|
if (!collection || !collection.sortedDocuments) {
|
|
return [];
|
|
}
|
|
|
|
const drafts = this.drafts({ collectionId });
|
|
|
|
return compact([
|
|
...drafts,
|
|
...collection.sortedDocuments.map((node) => this.get(node.id)),
|
|
]);
|
|
}
|
|
|
|
leastRecentlyUpdatedInCollection(collectionId: string): Document[] {
|
|
return orderBy(this.inCollection(collectionId), "updatedAt", "asc");
|
|
}
|
|
|
|
recentlyUpdatedInCollection(collectionId: string): Document[] {
|
|
return orderBy(this.inCollection(collectionId), "updatedAt", "desc");
|
|
}
|
|
|
|
recentlyPublishedInCollection(collectionId: string): Document[] {
|
|
return orderBy(
|
|
this.publishedInCollection(collectionId),
|
|
"publishedAt",
|
|
"desc"
|
|
);
|
|
}
|
|
|
|
alphabeticalInCollection(collectionId: string): Document[] {
|
|
return naturalSort(this.inCollection(collectionId), "title");
|
|
}
|
|
|
|
popularInCollection(collectionId: string): Document[] {
|
|
return orderBy(this.inCollection(collectionId), "popularityScore", "desc");
|
|
}
|
|
|
|
/**
|
|
* Evict every document belonging to a collection from the store, for use when
|
|
* the current user has lost access to the collection's documents.
|
|
*
|
|
* @param collectionId the ID of the collection to evict documents for.
|
|
*/
|
|
@action
|
|
removeInCollection(collectionId: string) {
|
|
this.orderedData
|
|
.filter((document) => document.collectionId === collectionId)
|
|
.forEach((document) => this.remove(document.id, { permanent: true }));
|
|
}
|
|
|
|
get(id: string): Document | undefined {
|
|
return id
|
|
? (this.data.get(id) ??
|
|
this.orderedData.find((doc) => id.endsWith(doc.urlId)))
|
|
: undefined;
|
|
}
|
|
|
|
@computed
|
|
get archived(): Document[] {
|
|
return orderBy(this.orderedData, "archivedAt", "desc").filter(
|
|
(d) => d.archivedAt && !d.deletedAt
|
|
);
|
|
}
|
|
|
|
@computed
|
|
get deleted(): Document[] {
|
|
return orderBy(this.orderedData, "deletedAt", "desc").filter(
|
|
(d) => d.deletedAt
|
|
);
|
|
}
|
|
|
|
@computed
|
|
get totalDrafts(): number {
|
|
return this.drafts().length;
|
|
}
|
|
|
|
drafts = (
|
|
options: PaginationParams & {
|
|
dateFilter?: DateFilter;
|
|
collectionId?: string;
|
|
} = {}
|
|
): Document[] => {
|
|
let drafts = filter(
|
|
orderBy(this.all, "updatedAt", "desc"),
|
|
(doc) => !doc.publishedAt
|
|
);
|
|
|
|
if (options.dateFilter) {
|
|
drafts = filter(
|
|
drafts,
|
|
(draft) =>
|
|
new Date(draft.updatedAt) >=
|
|
subtractDate(new Date(), options.dateFilter || "year")
|
|
);
|
|
}
|
|
|
|
if (options.collectionId) {
|
|
drafts = filter(drafts, {
|
|
collectionId: options.collectionId,
|
|
});
|
|
}
|
|
|
|
return drafts;
|
|
};
|
|
|
|
@computed
|
|
get active(): Document | undefined {
|
|
return this.rootStore.ui.activeDocumentId
|
|
? this.data.get(this.rootStore.ui.activeDocumentId)
|
|
: undefined;
|
|
}
|
|
|
|
@action
|
|
fetchRelationships = async (documentId: string): Promise<void> => {
|
|
const res = await client.post("/relationships.list", { documentId });
|
|
invariant(res?.data, "Relationships not available");
|
|
|
|
runInAction("DocumentsStore#fetchRelationships", () => {
|
|
res.data.documents.forEach(this.add);
|
|
this.addPolicies(res.policies);
|
|
|
|
const backlinkIds: string[] = [];
|
|
const similarIds: string[] = [];
|
|
|
|
for (const relationship of res.data.relationships) {
|
|
if (relationship.type === "backlink") {
|
|
backlinkIds.push(relationship.reverseDocumentId);
|
|
} else if (relationship.type === "similar") {
|
|
similarIds.push(relationship.reverseDocumentId);
|
|
}
|
|
}
|
|
|
|
this.backlinks.set(documentId, backlinkIds);
|
|
this.similar.set(documentId, similarIds);
|
|
});
|
|
};
|
|
|
|
getBacklinkedDocuments(documentId: string): Document[] {
|
|
const documentIds = this.backlinks.get(documentId) || [];
|
|
return orderBy(
|
|
compact(documentIds.map((id) => this.data.get(id))),
|
|
"title",
|
|
"asc"
|
|
);
|
|
}
|
|
|
|
getSimilarDocuments(documentId: string): Document[] {
|
|
const documentIds = this.similar.get(documentId) || [];
|
|
return orderBy(
|
|
compact(documentIds.map((id) => this.data.get(id))),
|
|
"title",
|
|
"asc"
|
|
);
|
|
}
|
|
|
|
@action
|
|
fetchChildDocuments = async (documentId: string): Promise<void> => {
|
|
const res = await client.post(`/documents.list`, {
|
|
parentDocumentId: documentId,
|
|
});
|
|
invariant(res?.data, "Document list not available");
|
|
|
|
runInAction("DocumentsStore#fetchChildDocuments", () => {
|
|
res.data.forEach(this.add);
|
|
this.addPolicies(res.policies);
|
|
});
|
|
};
|
|
|
|
@action
|
|
fetchNamedPage = async (
|
|
request = "list",
|
|
options: FetchPageParams | undefined
|
|
): Promise<Document[]> => {
|
|
this.isFetching = true;
|
|
|
|
try {
|
|
const res = await client.post(`/documents.${request}`, options);
|
|
invariant(res?.data, "Document list not available");
|
|
runInAction("DocumentsStore#fetchNamedPage", () => {
|
|
res.data.forEach(this.add);
|
|
this.addPolicies(res.policies);
|
|
this.isLoaded = true;
|
|
});
|
|
return res.data;
|
|
} finally {
|
|
this.isFetching = false;
|
|
}
|
|
};
|
|
|
|
@action
|
|
fetchArchived = async (options?: PaginationParams): Promise<Document[]> =>
|
|
this.fetchNamedPage("archived", options);
|
|
|
|
@action
|
|
fetchDeleted = async (options?: PaginationParams): Promise<Document[]> =>
|
|
this.fetchNamedPage("deleted", options);
|
|
|
|
@action
|
|
fetchRecentlyUpdated = async (
|
|
options?: PaginationParams
|
|
): Promise<Document[]> => this.fetchNamedPage("list", options);
|
|
|
|
@action
|
|
fetchAlphabetical = async (options?: PaginationParams): Promise<Document[]> =>
|
|
this.fetchNamedPage("list", {
|
|
sort: "title",
|
|
direction: "ASC",
|
|
...options,
|
|
});
|
|
|
|
@action
|
|
fetchLeastRecentlyUpdated = async (
|
|
options?: PaginationParams
|
|
): Promise<Document[]> =>
|
|
this.fetchNamedPage("list", {
|
|
sort: "updatedAt",
|
|
direction: "ASC",
|
|
...options,
|
|
});
|
|
|
|
@action
|
|
fetchRecentlyPublished = async (
|
|
options?: PaginationParams
|
|
): Promise<Document[]> =>
|
|
this.fetchNamedPage("list", {
|
|
sort: "publishedAt",
|
|
direction: "DESC",
|
|
...options,
|
|
});
|
|
|
|
@action
|
|
fetchRecentlyViewed = async (
|
|
options?: PaginationParams
|
|
): Promise<Document[]> => this.fetchNamedPage("viewed", options);
|
|
|
|
@action
|
|
fetchPopular = async (options?: PaginationParams): Promise<Document[]> =>
|
|
this.fetchNamedPage("list", {
|
|
sort: "popularityScore",
|
|
direction: "DESC",
|
|
...options,
|
|
});
|
|
|
|
@action
|
|
fetchStarred = (options?: PaginationParams): Promise<Document[]> =>
|
|
this.fetchNamedPage("starred", options);
|
|
|
|
@action
|
|
fetchDrafts = (options: PaginationParams = {}): Promise<Document[]> =>
|
|
this.fetchNamedPage("drafts", { limit: 100, ...options });
|
|
|
|
@action
|
|
fetchOwned = (options?: PaginationParams): Promise<Document[]> =>
|
|
this.fetchNamedPage("list", options);
|
|
|
|
@action
|
|
searchTitles = async (options?: SearchParams): Promise<SearchResult[]> => {
|
|
const compactedOptions = omitBy(options, (o) => !o);
|
|
const res = await client.post("/documents.search_titles", {
|
|
...compactedOptions,
|
|
});
|
|
invariant(res?.data, "Search response should be available");
|
|
|
|
// add the documents and associated policies to the store
|
|
runInAction("DocumentsStore#searchTitles", () => {
|
|
res.data.forEach(this.add);
|
|
this.addPolicies(res.policies);
|
|
});
|
|
|
|
// store a reference to the document model in the search cache instead
|
|
// of the original result from the API.
|
|
const results: SearchResult[] = compact(
|
|
res.data.map((result: SearchResult) => {
|
|
const document = this.data.get(result.id);
|
|
if (!document) {
|
|
return null;
|
|
}
|
|
return {
|
|
id: document.id,
|
|
document,
|
|
};
|
|
})
|
|
);
|
|
return results;
|
|
};
|
|
|
|
@action
|
|
search = async (options: SearchParams): Promise<SearchResult[]> => {
|
|
const compactedOptions = omitBy(options, (o) => !o);
|
|
const res = await client.post("/documents.search", {
|
|
...compactedOptions,
|
|
});
|
|
invariant(res?.data, "Search response should be available");
|
|
|
|
// add the documents and associated policies to the store
|
|
runInAction("DocumentsStore#search", () => {
|
|
res.data.forEach((result: SearchResult) => this.add(result.document));
|
|
this.addPolicies(res.policies);
|
|
});
|
|
|
|
// store a reference to the document model in the search cache instead
|
|
// of the original result from the API.
|
|
const results: SearchResult[] = compact(
|
|
res.data.map((result: SearchResult) => {
|
|
const document = this.data.get(result.document.id);
|
|
if (!document) {
|
|
return null;
|
|
}
|
|
return {
|
|
id: document.id,
|
|
ranking: result.ranking,
|
|
context: result.context,
|
|
document,
|
|
};
|
|
})
|
|
);
|
|
return results;
|
|
};
|
|
|
|
@action
|
|
prefetchDocument = async (id: string) => {
|
|
if (!this.get(id)) {
|
|
return this.fetch(id, {
|
|
prefetch: true,
|
|
});
|
|
}
|
|
|
|
return;
|
|
};
|
|
|
|
override fetch = (id: string, options: FetchOptions = {}) =>
|
|
super.fetch(
|
|
id,
|
|
options,
|
|
(res: { data: { document: PartialExcept<Document, "id"> } }) =>
|
|
res.data.document
|
|
);
|
|
|
|
@action
|
|
move = async ({
|
|
documentId,
|
|
collectionId,
|
|
parentDocumentId,
|
|
index,
|
|
}: {
|
|
documentId: string;
|
|
collectionId?: string | null;
|
|
parentDocumentId?: string | null;
|
|
index?: number | null;
|
|
}) => {
|
|
this.movingDocumentId = documentId;
|
|
|
|
try {
|
|
const res = await client.post("/documents.move", {
|
|
id: documentId,
|
|
collectionId,
|
|
parentDocumentId,
|
|
index,
|
|
});
|
|
invariant(res?.data, "Data not available");
|
|
res.data.documents.forEach(this.add);
|
|
this.addPolicies(res.policies);
|
|
|
|
// The websocket "documents.move" event is only broadcast to the
|
|
// collection channel, so users with document-only access never receive
|
|
// it. Refresh the affected membership tree locally so the sidebar
|
|
// reflects the new structure.
|
|
const membership =
|
|
this.rootStore.userMemberships.getByDocumentId(documentId);
|
|
if (membership) {
|
|
await membership.fetchDocuments({ force: true });
|
|
}
|
|
} finally {
|
|
this.movingDocumentId = undefined;
|
|
}
|
|
};
|
|
|
|
@action
|
|
duplicate = async (
|
|
document: Document,
|
|
options?: {
|
|
title?: string;
|
|
publish?: boolean;
|
|
recursive?: boolean;
|
|
}
|
|
): Promise<Document[]> => {
|
|
const res = await client.post("/documents.duplicate", {
|
|
id: document.id,
|
|
...options,
|
|
});
|
|
invariant(res?.data, "Data should be available");
|
|
|
|
this.addPolicies(res.policies);
|
|
return res.data.documents.map(this.add);
|
|
};
|
|
|
|
@action
|
|
import = async (
|
|
file: File,
|
|
parentDocumentId: string | null | undefined,
|
|
collectionId: string | null | undefined,
|
|
options: ImportOptions
|
|
) => {
|
|
// file.type can be an empty string sometimes
|
|
if (
|
|
file.type &&
|
|
!this.importFileTypes.includes(file.type) &&
|
|
!this.importFileTypes.includes(extname(file.name))
|
|
) {
|
|
throw new Error(`The selected file type is not supported (${file.type})`);
|
|
}
|
|
|
|
if (file.size > env.FILE_STORAGE_IMPORT_MAX_SIZE) {
|
|
throw new Error(
|
|
`The selected file was larger than the ${bytesToHumanReadable(
|
|
env.FILE_STORAGE_IMPORT_MAX_SIZE
|
|
)} maximum size`
|
|
);
|
|
}
|
|
|
|
const attachment = await uploadFile(file, {
|
|
name: file.name,
|
|
preset: AttachmentPreset.Import,
|
|
});
|
|
|
|
const res = await client.post(
|
|
"/documents.import",
|
|
{
|
|
attachmentId: attachment.id,
|
|
parentDocumentId,
|
|
collectionId,
|
|
publish: options.publish,
|
|
},
|
|
{
|
|
retry: false,
|
|
}
|
|
);
|
|
invariant(res?.data, "Data should be available");
|
|
this.addPolicies(res.policies);
|
|
return this.add(res.data);
|
|
};
|
|
|
|
@action
|
|
async delete(
|
|
document: Document,
|
|
options?: {
|
|
permanent: boolean;
|
|
}
|
|
) {
|
|
await super.delete(document, options);
|
|
|
|
// For permanent deletion, we need to actually remove the document from the
|
|
// local store data Map, as the base Store's remove() method only soft-deletes
|
|
// ParanoidModel instances by setting deletedAt.
|
|
if (options?.permanent) {
|
|
this.data.delete(document.id);
|
|
}
|
|
|
|
// check to see if we have any shares related to this document already
|
|
// loaded in local state. If so we can go ahead and remove those too.
|
|
const share = this.rootStore.shares.getByDocumentId(document.id);
|
|
|
|
if (share) {
|
|
this.rootStore.shares.remove(share.id);
|
|
}
|
|
|
|
const collection = this.getCollectionForDocument(document);
|
|
if (collection) {
|
|
await collection.refresh();
|
|
}
|
|
}
|
|
|
|
@action
|
|
archive = async (document: Document) => {
|
|
const res = await client.post("/documents.archive", {
|
|
id: document.id,
|
|
});
|
|
runInAction("Document#archive", () => {
|
|
invariant(res?.data, "Data should be available");
|
|
document.updateData(res.data);
|
|
this.addPolicies(res.policies);
|
|
});
|
|
const collection = this.getCollectionForDocument(document);
|
|
if (collection) {
|
|
collection.removeDocument(document.id);
|
|
}
|
|
};
|
|
|
|
@action
|
|
restore = async (
|
|
document: Document,
|
|
options: {
|
|
revisionId?: string;
|
|
collectionId?: string;
|
|
} = {}
|
|
) => {
|
|
const res = await client.post("/documents.restore", {
|
|
id: document.id,
|
|
revisionId: options.revisionId,
|
|
collectionId: options.collectionId,
|
|
});
|
|
runInAction("Document#restore", () => {
|
|
invariant(res?.data, "Data should be available");
|
|
document.updateData(res.data);
|
|
this.addPolicies(res.policies);
|
|
});
|
|
const collection = this.getCollectionForDocument(document);
|
|
if (collection) {
|
|
await collection.refresh();
|
|
}
|
|
};
|
|
|
|
@action
|
|
unpublish = async (
|
|
document: Document,
|
|
options: { detach?: boolean } = {
|
|
detach: false,
|
|
}
|
|
) => {
|
|
const res = await client.post("/documents.unpublish", {
|
|
id: document.id,
|
|
...options,
|
|
});
|
|
|
|
runInAction("Document#unpublish", () => {
|
|
invariant(res?.data, "Data should be available");
|
|
// unpublishing could sometimes detach the document from the collection.
|
|
// so, get the collection id before data is updated.
|
|
const collectionId = document.collectionId;
|
|
|
|
document.updateData(res.data);
|
|
this.addPolicies(res.policies);
|
|
|
|
if (collectionId) {
|
|
const collection = this.rootStore.collections.get(collectionId);
|
|
collection?.removeDocument(document.id);
|
|
}
|
|
});
|
|
};
|
|
|
|
@action
|
|
emptyTrash = async () => {
|
|
await client.post("/documents.empty_trash");
|
|
|
|
const documentIdsSet = new Set(this.deleted.map((doc) => doc.id));
|
|
// Call removeAll to handle inverse relations, policies, and lifecycle hooks
|
|
this.removeAll((doc: Document) => documentIdsSet.has(doc.id));
|
|
// For permanent deletion (empty trash), we need to hard delete from the store
|
|
// after the cleanup is done, as removeAll only soft-deletes ParanoidModel instances
|
|
documentIdsSet.forEach((id) => this.data.delete(id));
|
|
};
|
|
|
|
star = (document: Document, index?: string) =>
|
|
this.rootStore.stars.create({
|
|
documentId: document.id,
|
|
index,
|
|
});
|
|
|
|
unstar = (document: Document) => {
|
|
const star = this.rootStore.stars.orderedData.find(
|
|
(s) => s.documentId === document.id
|
|
);
|
|
return star?.delete();
|
|
};
|
|
|
|
subscribe = (document: Document) =>
|
|
this.rootStore.subscriptions.create({
|
|
documentId: document.id,
|
|
event: SubscriptionType.Document,
|
|
});
|
|
|
|
unsubscribe = (document: Document) => {
|
|
const subscription = this.rootStore.subscriptions.getByDocumentId(
|
|
document.id
|
|
);
|
|
|
|
return subscription?.delete();
|
|
};
|
|
|
|
getCollectionForDocument(document: Document) {
|
|
return document.collectionId
|
|
? this.rootStore.collections.get(document.collectionId)
|
|
: undefined;
|
|
}
|
|
}
|