mirror of
https://github.com/javierarce/arena-manager.git
synced 2026-07-22 10:30:30 +00:00
Three fixes to channel/block import on the are.na v3 API: - Embed (Media) blocks — YouTube, Vimeo, audio — now import with their iframe HTML so the embed renders in reading view, instead of producing empty notes. v3 returns these as `type: "Embed"` (not v2's "Media"), so the old class filters no longer matched and the content went unmapped. - Note filenames are capped at 200 chars (preserving a short extension) so blocks with very long titles no longer fail to write. - Channel length uses `counts.blocks` instead of `counts.contents`, so a channel whose only connection is a nested sub-channel no longer shows a phantom count and then imports nothing.
498 lines
13 KiB
TypeScript
498 lines
13 KiB
TypeScript
import { App, TFile, TAbstractFile, TFolder, requestUrl } from "obsidian";
|
|
|
|
import { Attachment } from "./types";
|
|
import { Settings, DOWNLOAD_ATTACHMENTS_TYPES } from "./Settings";
|
|
|
|
// Most filesystems cap a single path component at 255 bytes. Stay well under
|
|
// that so the appended `.md`, a `-<n>` de-duplication suffix, or an attachment
|
|
// extension can't push the final name over the limit. See issue #7.
|
|
const MAX_FILENAME_LENGTH = 200;
|
|
|
|
export default class Filemanager {
|
|
app: App;
|
|
settings: Settings;
|
|
|
|
constructor(app: App, settings: Settings) {
|
|
this.app = app;
|
|
this.settings = settings;
|
|
}
|
|
|
|
getSafeFilename(fileName: string) {
|
|
// Replace characters that are illegal in file names on common platforms
|
|
// (`\ / : * ? " < >`) or that break Obsidian links, tags and block refs
|
|
// (`| # ^ [ ]`) with spaces, then collapse the resulting whitespace.
|
|
// Falls back to "Untitled" if nothing usable remains. See issue #2.
|
|
const safe = fileName
|
|
.replace(/[\\/:*?"<>|#^[\]]/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
return this.truncateFilename(safe || "Untitled");
|
|
}
|
|
|
|
// Clamp an over-long name to MAX_FILENAME_LENGTH, preserving a short trailing
|
|
// extension (e.g. ".png") when present so truncated attachment names keep a
|
|
// valid type. Longer tails after the final dot are treated as prose, not an
|
|
// extension, and simply cut.
|
|
private truncateFilename(name: string): string {
|
|
if (name.length <= MAX_FILENAME_LENGTH) {
|
|
return name;
|
|
}
|
|
const dotIndex = name.lastIndexOf(".");
|
|
const hasExtension = dotIndex > 0 && name.length - dotIndex <= 6;
|
|
const extension = hasExtension ? name.slice(dotIndex) : "";
|
|
const stem = hasExtension ? name.slice(0, dotIndex) : name;
|
|
return stem.slice(0, MAX_FILENAME_LENGTH - extension.length) + extension;
|
|
}
|
|
|
|
// Block ids round-trip through YAML frontmatter, where they can come back as
|
|
// either a number (freshly written) or a string (manual edits, quoting, older
|
|
// notes — note getFilesWithBlockId coerces with Number() for the same reason).
|
|
// A strict `===` would treat "123" and 123 as different blocks and re-create
|
|
// the note on every sync, so compare them numerically.
|
|
sameBlockId(
|
|
a: string | number | null | undefined,
|
|
b: string | number | null | undefined,
|
|
): boolean {
|
|
if (a == null || a === "" || b == null || b === "") {
|
|
return false;
|
|
}
|
|
const na = Number(a);
|
|
const nb = Number(b);
|
|
return Number.isFinite(na) && Number.isFinite(nb) && na === nb;
|
|
}
|
|
|
|
async doesAttachmentExist(fileName: string): Promise<boolean> {
|
|
const filePath = `${this.settings.attachments_folder}/${fileName}`;
|
|
return this.doesFileExist(filePath);
|
|
}
|
|
|
|
async doesFileExist(filePath: string): Promise<boolean> {
|
|
const file = this.app.vault.getAbstractFileByPath(filePath);
|
|
return file !== null;
|
|
}
|
|
|
|
async renameFile(
|
|
filePath: TFile,
|
|
title: string,
|
|
content: string,
|
|
frontData: Record<string, string | number> = {},
|
|
attachment?: Attachment,
|
|
) {
|
|
const fileName = this.getSafeFilename(title);
|
|
let folderPath = this.settings.folder;
|
|
|
|
if (frontData.channel) {
|
|
folderPath = `${folderPath}/${frontData.channel}`;
|
|
}
|
|
|
|
if (
|
|
this.settings.download_attachments_type !==
|
|
DOWNLOAD_ATTACHMENTS_TYPES.none &&
|
|
attachment
|
|
) {
|
|
const attachmentFileName = await this.downloadAttachment(
|
|
attachment,
|
|
folderPath,
|
|
fileName,
|
|
frontData.blockid,
|
|
);
|
|
if (attachmentFileName) {
|
|
content = `![[${attachmentFileName}]]`;
|
|
}
|
|
}
|
|
|
|
const newName = `${folderPath}/${fileName}.md`;
|
|
await this.app.vault.modify(filePath, content);
|
|
await this.writeFrontmatter(filePath, frontData);
|
|
await this.app.vault.rename(filePath, newName);
|
|
}
|
|
|
|
getFileByFolderPathAndFileName(
|
|
folderPath: string,
|
|
fileName: string,
|
|
): TFile | null {
|
|
const normalizedFolderPath = folderPath.replace(/\\/g, "/");
|
|
const filePath = `${normalizedFolderPath}/${this.getSafeFilename(fileName)}.md`;
|
|
return this.app.vault.getFileByPath(filePath);
|
|
}
|
|
|
|
async createFolder(folderPath: string) {
|
|
const normalizedFolderPath = folderPath.replace(/\\/g, "/");
|
|
|
|
const folder =
|
|
this.app.vault.getAbstractFileByPath(normalizedFolderPath);
|
|
|
|
if (!folder) {
|
|
await this.app.vault.createFolder(normalizedFolderPath);
|
|
}
|
|
}
|
|
|
|
async updateFileWithFrontmatter(
|
|
folderPath: string,
|
|
fileName: string,
|
|
content: string,
|
|
frontData: Record<string, string | number> = {},
|
|
) {
|
|
const normalizedFolderPath = folderPath.replace(/\\/g, "/");
|
|
const filePath = `${normalizedFolderPath}/${this.getSafeFilename(fileName)}.md`;
|
|
|
|
const file = this.app.vault.getFileByPath(filePath);
|
|
if (file) {
|
|
await this.app.vault.modify(file, content);
|
|
await this.writeFrontmatter(file, frontData);
|
|
}
|
|
}
|
|
|
|
async createFileWithFrontmatter(
|
|
folderPath: string,
|
|
fileName: string,
|
|
content: string,
|
|
frontData: Record<string, string | number> = {},
|
|
) {
|
|
const normalizedFolderPath = folderPath.replace(/\\/g, "/");
|
|
const filePath = `${normalizedFolderPath}/${this.getSafeFilename(fileName)}.md`;
|
|
const file = await this.app.vault.create(filePath, content);
|
|
await this.writeFrontmatter(file, frontData);
|
|
}
|
|
|
|
getAttachmentFilenameFromTitle(
|
|
attachment: Attachment,
|
|
title?: string,
|
|
blockId?: string | number,
|
|
) {
|
|
let name = title || attachment.file_name;
|
|
if (name.endsWith(`.${attachment.extension}`)) {
|
|
name = name.slice(0, -attachment.extension.length - 1);
|
|
}
|
|
// Fold in the globally-unique block id so two blocks that share a title
|
|
// can't overwrite each other's media in a shared attachments folder, while
|
|
// re-downloading the same block keeps writing to the same file.
|
|
if (blockId != null && blockId !== "") {
|
|
name = `${name}-${blockId}`;
|
|
}
|
|
return this.getSafeFilename(`${name}.${attachment.extension}`);
|
|
}
|
|
|
|
async downloadAttachment(
|
|
attachment: Attachment,
|
|
folderPath: string,
|
|
filename: string,
|
|
blockId?: string | number,
|
|
): Promise<string | null> {
|
|
const request = await requestUrl(attachment.url);
|
|
|
|
if (
|
|
this.settings.download_attachments_type !=
|
|
DOWNLOAD_ATTACHMENTS_TYPES.none &&
|
|
this.settings.attachments_folder
|
|
) {
|
|
if (
|
|
this.settings.download_attachments_type ==
|
|
DOWNLOAD_ATTACHMENTS_TYPES.channel
|
|
) {
|
|
await this.createFolder(
|
|
`${folderPath}/${this.settings.attachments_folder}`,
|
|
);
|
|
} else if (
|
|
this.settings.download_attachments_type ==
|
|
DOWNLOAD_ATTACHMENTS_TYPES.custom
|
|
) {
|
|
await this.createFolder(this.settings.attachments_folder);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const attachmentFileName = this.getAttachmentFilenameFromTitle(
|
|
attachment,
|
|
filename,
|
|
blockId,
|
|
);
|
|
|
|
let attachmentFolderPath = this.settings.attachments_folder;
|
|
|
|
if (
|
|
this.settings.download_attachments_type ==
|
|
DOWNLOAD_ATTACHMENTS_TYPES.channel
|
|
) {
|
|
if (this.settings.attachments_folder) {
|
|
attachmentFolderPath = `${folderPath}/${this.settings.attachments_folder}`;
|
|
} else {
|
|
attachmentFolderPath = `${folderPath}`;
|
|
}
|
|
}
|
|
|
|
await this.app.vault.adapter.writeBinary(
|
|
`${attachmentFolderPath}/${attachmentFileName}`,
|
|
request.arrayBuffer,
|
|
);
|
|
|
|
if (
|
|
this.settings.download_attachments_type ==
|
|
DOWNLOAD_ATTACHMENTS_TYPES.channel
|
|
) {
|
|
if (this.settings.attachments_folder) {
|
|
return `${folderPath}/${this.settings.attachments_folder}/${attachmentFileName}`;
|
|
} else {
|
|
return `${folderPath}/${attachmentFileName}`;
|
|
}
|
|
} else {
|
|
return `${attachmentFolderPath}/${attachmentFileName}`;
|
|
}
|
|
} catch (error) {
|
|
console.error("Error downloading attachment", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async findNextAvailableFileName(
|
|
folderPath: string,
|
|
baseFileName: string,
|
|
blockId: string | number,
|
|
): Promise<string> {
|
|
// Sanitize up front so the existence checks below probe the same paths
|
|
// the file actually gets written to (see createFileWithFrontmatter).
|
|
baseFileName = this.getSafeFilename(baseFileName);
|
|
let counter = 0;
|
|
let filePath = `${folderPath}/${baseFileName}.md`;
|
|
|
|
while (await this.doesFileExist(filePath)) {
|
|
const file = this.app.vault.getAbstractFileByPath(filePath);
|
|
|
|
if (file instanceof TFile) {
|
|
const frontmatter = await this.getFrontmatterFromFile(file);
|
|
|
|
if (this.sameBlockId(frontmatter.blockid, blockId)) {
|
|
// If we find a file with the same blockId, reuse this file
|
|
if (counter === 0) {
|
|
return baseFileName;
|
|
} else {
|
|
return `${baseFileName}-${counter}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If blockId is different, increment counter and try next filename
|
|
counter++;
|
|
filePath = `${folderPath}/${baseFileName}-${counter}.md`;
|
|
}
|
|
|
|
return `${baseFileName}-${counter}`;
|
|
}
|
|
|
|
async updateFile(
|
|
file: TFile,
|
|
folderPath: string,
|
|
fileName: string,
|
|
content: string,
|
|
frontData: Record<string, string | number> = {},
|
|
attachment?: Attachment,
|
|
) {
|
|
const blockId = await this.getBlockIdFromFile(file);
|
|
|
|
if (this.sameBlockId(blockId, frontData?.blockid)) {
|
|
if (
|
|
this.settings.download_attachments_type !==
|
|
DOWNLOAD_ATTACHMENTS_TYPES.none &&
|
|
attachment
|
|
) {
|
|
const attachmentFileName = await this.downloadAttachment(
|
|
attachment,
|
|
folderPath,
|
|
fileName,
|
|
frontData.blockid,
|
|
);
|
|
if (attachmentFileName) {
|
|
content = `![[${attachmentFileName}]]`;
|
|
}
|
|
}
|
|
|
|
// If the blockid is the same, update the file
|
|
await this.updateFileWithFrontmatter(
|
|
folderPath,
|
|
fileName,
|
|
content,
|
|
frontData,
|
|
);
|
|
} else {
|
|
// If the blockid is different, create a new file
|
|
const baseFileName = `${fileName.split(".")[0]}`;
|
|
const newFilename = await this.findNextAvailableFileName(
|
|
folderPath,
|
|
baseFileName,
|
|
frontData.blockid,
|
|
);
|
|
|
|
if (
|
|
this.settings.download_attachments_type !==
|
|
DOWNLOAD_ATTACHMENTS_TYPES.none &&
|
|
attachment
|
|
) {
|
|
const attachmentFileName = await this.downloadAttachment(
|
|
attachment,
|
|
folderPath,
|
|
newFilename,
|
|
frontData.blockid,
|
|
);
|
|
if (attachmentFileName) {
|
|
content = `![[${attachmentFileName}]]`;
|
|
}
|
|
}
|
|
|
|
const newFile = this.getFileByFolderPathAndFileName(
|
|
folderPath,
|
|
newFilename,
|
|
);
|
|
|
|
if (!newFile) {
|
|
await this.createFileWithFrontmatter(
|
|
folderPath,
|
|
newFilename,
|
|
content,
|
|
frontData,
|
|
);
|
|
} else {
|
|
await this.updateFileWithFrontmatter(
|
|
folderPath,
|
|
newFilename,
|
|
content,
|
|
frontData,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async writeFile(
|
|
folderPath: string,
|
|
fileName: string,
|
|
content: string,
|
|
frontData: Record<string, string | number> = {},
|
|
attachment?: Attachment,
|
|
) {
|
|
await this.createFolder(folderPath);
|
|
const file = this.getFileByFolderPathAndFileName(folderPath, fileName);
|
|
|
|
if (!content) {
|
|
content = "";
|
|
console.warn("Empty content");
|
|
}
|
|
|
|
if (!file) {
|
|
if (
|
|
this.settings.download_attachments_type !==
|
|
DOWNLOAD_ATTACHMENTS_TYPES.none &&
|
|
attachment
|
|
) {
|
|
const attachmentFileName = await this.downloadAttachment(
|
|
attachment,
|
|
folderPath,
|
|
fileName,
|
|
frontData.blockid,
|
|
);
|
|
if (attachmentFileName) {
|
|
content = `![[${attachmentFileName}]]`;
|
|
}
|
|
}
|
|
|
|
await this.createFileWithFrontmatter(
|
|
folderPath,
|
|
fileName,
|
|
content,
|
|
frontData,
|
|
);
|
|
} else {
|
|
await this.updateFile(
|
|
file,
|
|
folderPath,
|
|
fileName,
|
|
content,
|
|
frontData,
|
|
attachment,
|
|
);
|
|
}
|
|
}
|
|
|
|
async getFrontmatterFromFile(
|
|
file: TFile,
|
|
): Promise<Record<string, string | number>> {
|
|
let frontmatterData: Record<string, string | number> = {};
|
|
|
|
await this.app.fileManager.processFrontMatter(
|
|
file,
|
|
(frontmatter: Record<string, string | number>) => {
|
|
frontmatterData = frontmatter || null;
|
|
},
|
|
);
|
|
|
|
return frontmatterData;
|
|
}
|
|
|
|
async getBlockIdFromFile(file: TFile): Promise<number | null> {
|
|
let blockId: number | null = null;
|
|
|
|
await this.app.fileManager.processFrontMatter(
|
|
file,
|
|
(frontmatter: Record<string, number | null>) => {
|
|
blockId = frontmatter.blockid || null;
|
|
},
|
|
);
|
|
|
|
return blockId;
|
|
}
|
|
|
|
async writeFrontmatter(
|
|
file: TFile,
|
|
frontData: Record<string, string | number>,
|
|
) {
|
|
if (!frontData) {
|
|
return;
|
|
}
|
|
await this.app.fileManager.processFrontMatter(
|
|
file,
|
|
(frontmatter: Record<string, string | number>) => {
|
|
Object.entries(frontData).forEach(([key, value]) => {
|
|
frontmatter[key] = value;
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
isMarkdownFile(this: void, file: TAbstractFile): file is TFile {
|
|
return file instanceof TFile && file.extension === "md";
|
|
}
|
|
|
|
async getFilesWithBlockId(
|
|
folderPath: string,
|
|
): Promise<{ name: string; blockid: number }[]> {
|
|
const result: { name: string; blockid: number }[] = [];
|
|
|
|
const folder = this.app.vault.getAbstractFileByPath(folderPath);
|
|
if (!folder || !(folder instanceof TFolder)) {
|
|
throw new Error("Directory not found or not a folder");
|
|
}
|
|
|
|
const files = folder.children.filter(this.isMarkdownFile);
|
|
|
|
for (const file of files) {
|
|
await this.app.fileManager.processFrontMatter(
|
|
file,
|
|
(frontmatter: Record<string, string | number>) => {
|
|
if (frontmatter.blockid) {
|
|
const blockid = Number(frontmatter.blockid);
|
|
if (!isNaN(blockid)) {
|
|
result.push({
|
|
name: file.name,
|
|
blockid: blockid,
|
|
});
|
|
} else {
|
|
console.warn(
|
|
`Invalid blockid for file ${file.name}: ${frontmatter.blockid}`,
|
|
);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|