mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83361f4dbb | |||
| b5364bdc60 | |||
| 455998074c | |||
| e5e9790c59 | |||
| 6d795e5d73 | |||
| 713e27c14a |
@@ -91,7 +91,7 @@ function Import() {
|
||||
<div>
|
||||
<Item
|
||||
border={false}
|
||||
image={<OutlineLogo size={28} fill="currentColor" />}
|
||||
image={<OutlineLogo size={28} />}
|
||||
title="Outline"
|
||||
subtitle={t(
|
||||
"Import a backup file that was previously exported from Outline"
|
||||
|
||||
@@ -82,4 +82,67 @@ describe("collectionImporter", () => {
|
||||
"Uploaded file does not contain importable documents"
|
||||
);
|
||||
});
|
||||
|
||||
it("should generate valid links in documents when the import zip has relatively linked documents", async () => {
|
||||
const user = await buildUser();
|
||||
const name = "Import-Export.zip";
|
||||
const file = new File({
|
||||
name,
|
||||
type: "application/zip",
|
||||
path: path.resolve(__dirname, "..", "test", "fixtures", name),
|
||||
});
|
||||
const response = await collectionImporter({
|
||||
type: "outline",
|
||||
user,
|
||||
file,
|
||||
ip,
|
||||
});
|
||||
|
||||
expect(response.collections.length).toEqual(1);
|
||||
expect(response.documents.length).toEqual(8);
|
||||
expect(response.attachments.length).toEqual(2);
|
||||
expect(await Collection.count()).toEqual(1);
|
||||
expect(await Document.count()).toEqual(8);
|
||||
expect(await Attachment.count()).toEqual(2);
|
||||
const brainStorm = response.documents.find(
|
||||
(doc) => doc.title === "Brainstorm"
|
||||
);
|
||||
const meetingNotes = response.documents.find(
|
||||
(doc) => doc.title === "Meeting Notes"
|
||||
);
|
||||
const tables = response.documents.find((doc) => doc.title === "Tables");
|
||||
const github = response.documents.find((doc) => doc.title === "GitHub");
|
||||
const myfirstDoc = response.documents.find(
|
||||
(doc) => doc.title === "My first document"
|
||||
);
|
||||
const codeTesting = response.documents.find(
|
||||
(doc) => doc.title === "Code Testing"
|
||||
);
|
||||
const testDoc = response.documents.find((doc) => doc.title === "Test Doc");
|
||||
|
||||
expect(brainStorm).toBeDefined();
|
||||
expect(meetingNotes).toBeDefined();
|
||||
expect(tables).toBeDefined();
|
||||
expect(github).toBeDefined();
|
||||
expect(myfirstDoc).toBeDefined();
|
||||
expect(codeTesting).toBeDefined();
|
||||
expect(testDoc).toBeDefined();
|
||||
|
||||
expect(brainStorm?.text).toContain(
|
||||
`- [ ] Add notes in [Meeting Notes](/doc/meeting-notes-${meetingNotes?.urlId}) of the last week all hands.`
|
||||
);
|
||||
expect(brainStorm?.text).toContain(
|
||||
`- [ ] See If we can update the table in [Tables](/doc/tables-${tables?.urlId})`
|
||||
);
|
||||
expect(github?.text).toContain(
|
||||
`We should do some [Code Testing](/doc/code-testing-${codeTesting?.urlId}) of the attached GitHub gist.`
|
||||
);
|
||||
expect(myfirstDoc?.text).toContain(
|
||||
`This is a test link to [Test Doc](/doc/test-doc-${testDoc?.urlId})`
|
||||
);
|
||||
// My first doc has reference to itself, we don't do anything to it.
|
||||
expect(myfirstDoc?.text).toContain(
|
||||
"This is a test link to myself [My first document](/doc/my-first-document-NTU3G1Vbin)"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,6 +188,34 @@ async function collectionImporter({
|
||||
}
|
||||
}
|
||||
|
||||
const updatingRelativeLinksToDocumentUrl = Object.keys(documents).map(
|
||||
(pathInZip) => {
|
||||
const document = documents[pathInZip];
|
||||
const relativeLinks = [...document.text.matchAll(/]\((.\/.*.md)\)/g)].map(
|
||||
(match) => "." + decodeURI(match[1])
|
||||
);
|
||||
|
||||
relativeLinks.forEach((relativeLink) => {
|
||||
const linkedDocumentPath = path
|
||||
.resolve(pathInZip, relativeLink)
|
||||
.replace(process.cwd() + "/", "");
|
||||
const linkedDocument = documents[linkedDocumentPath];
|
||||
if (linkedDocument) {
|
||||
document.text = document.text.replace(
|
||||
encodeURI(relativeLink).slice(1),
|
||||
linkedDocument.url
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return document.save({
|
||||
fields: ["text"],
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.all(updatingRelativeLinksToDocumentUrl);
|
||||
|
||||
// reload collections to get document mapping
|
||||
for (const collection of values(collections)) {
|
||||
await collection.reload();
|
||||
|
||||
BIN
Binary file not shown.
+79
-20
@@ -25,7 +25,8 @@ export type Item = {
|
||||
|
||||
async function addDocumentTreeToArchive(
|
||||
zip: JSZip,
|
||||
documents: NavigationNode[]
|
||||
documents: NavigationNode[],
|
||||
documentPathInArchive: Map<string, string>
|
||||
) {
|
||||
for (const doc of documents) {
|
||||
const document = await Document.findByPk(doc.id);
|
||||
@@ -47,6 +48,22 @@ async function addDocumentTreeToArchive(
|
||||
text = text.replace(attachment.redirectUrl, encodeURI(attachment.key));
|
||||
}
|
||||
|
||||
const matchedDocumentUrls = [...text.matchAll(/\/doc\/[\w+-]+\w{10}/g)];
|
||||
|
||||
matchedDocumentUrls.forEach((match) => {
|
||||
const matchedUrl = match[0];
|
||||
const pathofCurrentDoc = documentPathInArchive.get(document.url);
|
||||
const matchedDocPath = documentPathInArchive.get(matchedUrl);
|
||||
|
||||
if (matchedDocPath && pathofCurrentDoc) {
|
||||
const relativePath = path.relative(pathofCurrentDoc, matchedDocPath);
|
||||
|
||||
if (relativePath.startsWith(".")) {
|
||||
text = text.replace(matchedUrl, encodeURI(relativePath.substring(1)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let title = serializeFilename(document.title) || "Untitled";
|
||||
|
||||
title = safeAddFileToArchive(zip, `${title}.md`, text, {
|
||||
@@ -61,7 +78,11 @@ async function addDocumentTreeToArchive(
|
||||
const folder = zip.folder(path.parse(title).name);
|
||||
|
||||
if (folder) {
|
||||
await addDocumentTreeToArchive(folder, doc.children);
|
||||
await addDocumentTreeToArchive(
|
||||
folder,
|
||||
doc.children,
|
||||
documentPathInArchive
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +109,26 @@ async function addImageToArchive(zip: JSZip, key: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const getSafeFilename = (files: string[], root: string, key: string) => {
|
||||
const keysInDirectory = files
|
||||
.filter((k) => k.includes(root))
|
||||
.filter((k) => !k.endsWith("/"))
|
||||
.map((k) => path.basename(k).replace(/\s\((\d+)\)\./, "."));
|
||||
|
||||
// The number of duplicate filenames
|
||||
const existingKeysCount = keysInDirectory.filter((t) => t === key).length;
|
||||
const filename = path.parse(key).name;
|
||||
const extension = path.extname(key);
|
||||
|
||||
// Construct the new de-duplicated filename (if any)
|
||||
const safeKey =
|
||||
existingKeysCount > 0
|
||||
? `${filename} (${existingKeysCount})${extension}`
|
||||
: key;
|
||||
|
||||
return safeKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds content to a zip file, if the given filename already exists in the zip
|
||||
* then it will automatically increment numbers at the end of the filename.
|
||||
@@ -106,23 +147,7 @@ function safeAddFileToArchive(
|
||||
) {
|
||||
// @ts-expect-error root exists
|
||||
const root = zip.root;
|
||||
|
||||
// Filenames in the directory already
|
||||
const keysInDirectory = Object.keys(zip.files)
|
||||
.filter((k) => k.includes(root))
|
||||
.filter((k) => !k.endsWith("/"))
|
||||
.map((k) => path.basename(k).replace(/\s\((\d+)\)\./, "."));
|
||||
|
||||
// The number of duplicate filenames
|
||||
const existingKeysCount = keysInDirectory.filter((t) => t === key).length;
|
||||
const filename = path.parse(key).name;
|
||||
const extension = path.extname(key);
|
||||
|
||||
// Construct the new de-duplicated filename (if any)
|
||||
const safeKey =
|
||||
existingKeysCount > 0
|
||||
? `${filename} (${existingKeysCount})${extension}`
|
||||
: key;
|
||||
const safeKey = getSafeFilename(Object.keys(zip.files), root, key);
|
||||
|
||||
zip.file(safeKey, content, options);
|
||||
return safeKey;
|
||||
@@ -158,15 +183,49 @@ async function archiveToPath(zip: JSZip) {
|
||||
});
|
||||
}
|
||||
|
||||
function getDocumentPathInArchive(
|
||||
documents: NavigationNode[] | null,
|
||||
path: string,
|
||||
mapper: Map<string, string>
|
||||
) {
|
||||
if (!documents) {
|
||||
return;
|
||||
}
|
||||
for (const doc of documents) {
|
||||
const title = serializeFilename(doc.title) || "Untitled";
|
||||
const filename = getSafeFilename([...mapper.values()], path, `${title}.md`);
|
||||
mapper.set(doc.url, path + "/" + filename);
|
||||
|
||||
if (doc.children && doc.children.length) {
|
||||
getDocumentPathInArchive(doc.children, path + "/" + title, mapper);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function calculateDocumentPaths(collections: Collection[]) {
|
||||
const mapper = new Map<string, string>();
|
||||
for (const collection of collections) {
|
||||
const basePath = "/" + path.parse(collection.name).name;
|
||||
getDocumentPathInArchive(collection.documentStructure, basePath, mapper);
|
||||
}
|
||||
|
||||
return mapper;
|
||||
}
|
||||
|
||||
export async function archiveCollections(collections: Collection[]) {
|
||||
const zip = new JSZip();
|
||||
const documentPathInArchive = calculateDocumentPaths(collections);
|
||||
|
||||
for (const collection of collections) {
|
||||
if (collection.documentStructure) {
|
||||
const folder = zip.folder(collection.name);
|
||||
|
||||
if (folder) {
|
||||
await addDocumentTreeToArchive(folder, collection.documentStructure);
|
||||
await addDocumentTreeToArchive(
|
||||
folder,
|
||||
collection.documentStructure,
|
||||
documentPathInArchive
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
"Import document": "Import document",
|
||||
"Templatize": "Templatize",
|
||||
"Create template": "Create template",
|
||||
"Home": "Home",
|
||||
"Search documents for \"{{searchQuery}}\"": "Search documents for \"{{searchQuery}}\"",
|
||||
"Home": "Home",
|
||||
"Drafts": "Drafts",
|
||||
"Templates": "Templates",
|
||||
"Archive": "Archive",
|
||||
@@ -135,6 +135,7 @@
|
||||
"Change Language": "Change Language",
|
||||
"Dismiss": "Dismiss",
|
||||
"Back": "Back",
|
||||
"Documents": "Documents",
|
||||
"Document archived": "Document archived",
|
||||
"Move document": "Move document",
|
||||
"Collections": "Collections",
|
||||
@@ -290,7 +291,6 @@
|
||||
"Suspend account": "Suspend account",
|
||||
"API token created": "API token created",
|
||||
"Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".": "Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".",
|
||||
"Documents": "Documents",
|
||||
"The document archive is empty at the moment.": "The document archive is empty at the moment.",
|
||||
"This collection is only visible to those given access": "This collection is only visible to those given access",
|
||||
"Private": "Private",
|
||||
@@ -429,6 +429,7 @@
|
||||
"Offline": "Offline",
|
||||
"We were unable to load the document while offline.": "We were unable to load the document while offline.",
|
||||
"Your account has been suspended": "Your account has been suspended",
|
||||
"Warning Sign": "Warning Sign",
|
||||
"A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.",
|
||||
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.",
|
||||
"You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "You can edit the name of this group at any time, however doing so too often might confuse your team mates.",
|
||||
@@ -520,6 +521,7 @@
|
||||
"Author": "Author",
|
||||
"We were unable to find the page you’re looking for.": "We were unable to find the page you’re looking for.",
|
||||
"No documents found for your search filters.": "No documents found for your search filters.",
|
||||
"Search Results": "Search Results",
|
||||
"Processing": "Processing",
|
||||
"Expired": "Expired",
|
||||
"Failed": "Failed",
|
||||
@@ -617,7 +619,6 @@
|
||||
"Sharing is currently disabled.": "Sharing is currently disabled.",
|
||||
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</em>.",
|
||||
"Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.",
|
||||
"Shared documents": "Shared documents",
|
||||
"Whoops, you need to accept the permissions in Slack to connect Outline to your team. Try again?": "Whoops, you need to accept the permissions in Slack to connect Outline to your team. Try again?",
|
||||
"Something went wrong while authenticating your request. Please try logging in again?": "Something went wrong while authenticating your request. Please try logging in again?",
|
||||
"Get rich previews of Outline links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat.": "Get rich previews of Outline links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat.",
|
||||
|
||||
@@ -4605,24 +4605,6 @@ boolbase@^1.0.0, boolbase@~1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
|
||||
integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
|
||||
|
||||
boundless-arrow-key-navigation@^1.0.4:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/boundless-arrow-key-navigation/-/boundless-arrow-key-navigation-1.1.0.tgz#9b7908a32e2e8f8c1c6af3af68586fdcfe5c40ff"
|
||||
integrity sha1-m3kIoy4uj4wcavOvaFhv3P5cQP8=
|
||||
dependencies:
|
||||
boundless-utils-omit-keys "^1.1.0"
|
||||
boundless-utils-uuid "^1.1.0"
|
||||
|
||||
boundless-utils-omit-keys@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/boundless-utils-omit-keys/-/boundless-utils-omit-keys-1.1.0.tgz#fae73cdb90c113d56201d0b62e8f1143e0d193be"
|
||||
integrity sha1-+uc825DBE9ViAdC2Lo8RQ+DRk74=
|
||||
|
||||
boundless-utils-uuid@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/boundless-utils-uuid/-/boundless-utils-uuid-1.1.0.tgz#ae709f1d4fd3a4557ad4a5c77b1f0a9f701e3ed3"
|
||||
integrity sha1-rnCfHU/TpFV61KXHex8Kn3AePtM=
|
||||
|
||||
bowser@2.9.0:
|
||||
version "2.9.0"
|
||||
resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.9.0.tgz#3bed854233b419b9a7422d9ee3e85504373821c9"
|
||||
|
||||
Reference in New Issue
Block a user