javierarce_arena-manager/lib/FileHandler.ts
javierarce da19e7eff0
Import Embed/Media blocks; fix channel counts and long filenames
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.
2026-05-31 09:39:43 +02:00

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;
}
}