diff --git a/src/base/dom/drag-drop.ts b/src/base/dom/drag-drop.ts index d79527d5..fdef1ee2 100644 --- a/src/base/dom/drag-drop.ts +++ b/src/base/dom/drag-drop.ts @@ -6,11 +6,11 @@ import { DragAndDropFileSystem, - VirtualFileSystem, + type IFileSystem, } from "../../kicanvas/services/vfs"; export class DropTarget { - constructor(elm: HTMLElement, callback: (fs: VirtualFileSystem) => void) { + constructor(elm: HTMLElement, callback: (fs: IFileSystem) => void) { elm.addEventListener( "dragenter", (e) => { diff --git a/src/base/paths.ts b/src/base/paths.ts index 1fafb2cd..5f1ade83 100644 --- a/src/base/paths.ts +++ b/src/base/paths.ts @@ -21,3 +21,31 @@ export function basename(path: string | URL) { export function extension(path: string) { return path.split(".").at(-1) ?? ""; } + +/** + * Path.join and normalize the result, + * It DOES NOT check relative path or absolute path. + * + `('', '/qwq')` -> `qwq` + * + `('///', '/qwq/', 'file')` -> `qwq/file` + */ +export function normalize_join(...parts: string[]): string { + return parts + .flatMap((p) => p.split("/")) + .filter((s) => s !== "") + .join("/"); +} + +/** + * Return relative path of `absolute` based on `parent`. + * + `('qwq/abc', 'qwq/abc/def/file')` -> `def/file` + */ +export function based_on(parent: string, absolute: string): string { + if (parent === absolute) { + return ""; + } + const base = normalize_join(parent); + const prefix = base.length > 0 ? base + "/" : ""; + return absolute.startsWith(prefix) + ? absolute.slice(prefix.length) + : absolute; +} diff --git a/src/kicanvas/elements/kicanvas-embed.ts b/src/kicanvas/elements/kicanvas-embed.ts index 2abbf74f..c21bc7ca 100644 --- a/src/kicanvas/elements/kicanvas-embed.ts +++ b/src/kicanvas/elements/kicanvas-embed.ts @@ -20,7 +20,7 @@ import { FetchFileSystem, LocalFileSystem, MergedFileSystem, - VirtualFileSystem, + type IFileSystem, } from "../services/vfs"; import type { KCBoardAppElement } from "./kc-board/app"; import type { KCSchematicAppElement } from "./kc-schematic/app"; @@ -159,11 +159,12 @@ class KiCanvasEmbedElement extends KCUIElement { await this.#setup_project(vfs); } - async #setup_project(vfs: VirtualFileSystem) { + async #setup_project(vfs: IFileSystem) { this.loaded = false; this.loading = true; try { + await vfs.setup(); await this.#project.load(vfs); this.loaded = true; diff --git a/src/kicanvas/elements/kicanvas-shell.ts b/src/kicanvas/elements/kicanvas-shell.ts index 9dc8947e..269356a8 100644 --- a/src/kicanvas/elements/kicanvas-shell.ts +++ b/src/kicanvas/elements/kicanvas-shell.ts @@ -13,7 +13,7 @@ import { sprites_url } from "../icons/sprites"; import { Project } from "../project"; import { GitHubFileSystem } from "../services/github-vfs"; import { CodebergFileSystem } from "../services/codeberg-vfs"; -import { FetchFileSystem, type VirtualFileSystem } from "../services/vfs"; +import { FetchFileSystem, type IFileSystem } from "../services/vfs"; import { KCBoardAppElement } from "./kc-board/app"; import { KCSchematicAppElement } from "./kc-schematic/app"; @@ -88,6 +88,9 @@ class KiCanvasShellElement extends KCUIElement { ...url_params.getAll("repo"), ]; + // Only load the first URL + const url = urls[0]; + later(async () => { if (this.src) { const vfs = new FetchFileSystem([this.src]); @@ -95,8 +98,8 @@ class KiCanvasShellElement extends KCUIElement { return; } - if (urls.length) { - const vfs = await this.load_repo(...urls); + if (url) { + const vfs = await this.load_repo(url); if (!vfs) { return; } @@ -134,20 +137,19 @@ class KiCanvasShellElement extends KCUIElement { }); } - private async load_repo( - ...url: string[] - ): Promise { + private async load_repo(url: string): Promise { return ( - (await GitHubFileSystem.fromURLs(...url)) ?? - (await CodebergFileSystem.fromURLs(...url)) + (await GitHubFileSystem.fromURLs(url)) ?? + (await CodebergFileSystem.fromURLs(url)) ); } - private async setup_project(vfs: VirtualFileSystem) { + private async setup_project(vfs: IFileSystem) { this.loaded = false; this.loading = true; try { + await vfs.setup(); await this.project.load(vfs); this.project.set_active_page(this.project.first_page); this.loaded = true; diff --git a/src/kicanvas/project.ts b/src/kicanvas/project.ts index 0a33adc0..03cc0f0f 100644 --- a/src/kicanvas/project.ts +++ b/src/kicanvas/project.ts @@ -9,18 +9,19 @@ import { Barrier } from "../base/async"; import { type IDisposable } from "../base/disposable"; import { first, length, map } from "../base/iterator"; import { Logger } from "../base/log"; +import { dirname, normalize_join } from "../base/paths"; import { is_string, type Constructor } from "../base/types"; import { KicadPCB, KicadSch, ProjectSettings } from "../kicad"; import type { SchematicSheet, SchematicSheetInstance, } from "../kicad/schematic"; -import type { VirtualFileSystem } from "./services/vfs"; +import type { IFileSystem } from "./services/vfs"; const log = new Logger("kicanvas:project"); export class Project extends EventTarget implements IDisposable { - #fs: VirtualFileSystem; + #fs: IFileSystem; #files_by_name: Map = new Map(); #pages_by_path: Map = new Map(); #root_schematic_page?: ProjectPage; @@ -33,7 +34,7 @@ export class Project extends EventTarget implements IDisposable { this.#pages_by_path.clear(); } - public async load(fs: VirtualFileSystem) { + public async load(fs: IFileSystem) { log.info(`Loading project from ${fs.constructor.name}`); this.settings = new ProjectSettings(); @@ -42,30 +43,53 @@ export class Project extends EventTarget implements IDisposable { this.#fs = fs; - let promises = []; + const proj_promises = []; + // load project file for (const filename of this.#fs.list()) { - promises.push(this.#load_file(filename)); + proj_promises.push(this.#load_file(filename)); } - await Promise.all(promises); + await Promise.all(proj_promises); - while (promises.length) { - // 'Recursively' resolve all schematics until none remain - promises = []; - for (const schematic of this.schematics()) { - for (const sheet of schematic.sheets) { - const sheet_sch = this.#files_by_name.get( - sheet.sheetfile ?? "", - ) as KicadSch; + // 'Recursively' resolve all schematics until none remain + let load_new = true; + const skipped_files: string[] = []; + while (load_new) { + load_new = false; - if (!sheet_sch && sheet.sheetfile) { - // Missing schematic, attempt to fetch - promises.push(this.#load_file(sheet.sheetfile)); + const loaded_file = Array.from(this.schematics()); + for (const sch of loaded_file) { + const base_dir = dirname(sch.filename); + for (const subsch of sch.sheets) { + if (!subsch.sheetfile) { + continue; + } + + const new_file = normalize_join(base_dir, subsch.sheetfile); + + const loaded = loaded_file.map((s) => s.filename); + if ( + loaded.includes(new_file) || + skipped_files.includes(new_file) + ) { + // file loaded or skipped + continue; + } + + load_new = true; + + if (await this.#fs.has(new_file)) { + // load file, it changes this.#files_by_name and causes calling + // this.schematics() will return a new result. + await this.#load_file(new_file); + } else { + // skip non-existent files to allow loading an incomplete schematics + skipped_files.push(new_file); + log.warn(`file "${new_file}" is not existed, skip it.`); } } } - await Promise.all(promises); } this.#determine_schematic_hierarchy(); @@ -251,6 +275,10 @@ export class Project extends EventTarget implements IDisposable { // Finally, if no root schematic was found, just use the first one we saw. this.#root_schematic_page = first(this.#pages_by_path.values()); + + if (!this.#root_schematic_page) { + log.error("No vaild root schematic was found."); + } } public *files() { diff --git a/src/kicanvas/services/codeberg-vfs.ts b/src/kicanvas/services/codeberg-vfs.ts index 8204855e..5e6a140c 100644 --- a/src/kicanvas/services/codeberg-vfs.ts +++ b/src/kicanvas/services/codeberg-vfs.ts @@ -7,53 +7,50 @@ import { base64_decode } from "../../base/base64"; import { initiate_download } from "../../base/dom/download"; import { extension } from "../../base/paths"; import { Codeberg, GetBlobResponse, RepoContentResponse } from "./codeberg"; -import { VirtualFileSystem } from "./vfs"; +import { type IFileSystem } from "./vfs"; -export class CodebergFileSystem extends VirtualFileSystem { +export class CodebergFileSystem implements IFileSystem { static readonly kicad_extensions = ["kicad_pcb", "kicad_pro", "kicad_sch"]; - constructor(private files_to_urls: Map) { - super(); - } + async setup() {} + constructor(private files_to_urls: Map) {} public static async fromURLs( - ...urls: (string | URL)[] + url: string | URL, ): Promise { const files_to_urls = new Map(); - for (const url of urls) { - const info = Codeberg.parse_url(url); - if (!info) { - continue; - } - - // API: - // https://codeberg.org/api/swagger#/repository/repoGetContents - const api_url = `repos/${info.owner}/${info.repo}/contents/${info.path}`; + const info = Codeberg.parse_url(url); + if (!info) { + return null; + } - let files = await Codeberg.request_json< - RepoContentResponse | RepoContentResponse[] - >(api_url); + // API: + // https://codeberg.org/api/swagger#/repository/repoGetContents + const api_url = `repos/${info.owner}/${info.repo}/contents/${info.path}`; - if (!Array.isArray(files)) { - files = [files]; - } + let files = await Codeberg.request_json< + RepoContentResponse | RepoContentResponse[] + >(api_url); - for (const file of files) { - if (!file.name || !file.git_url || file.type !== "file") { - continue; - } + if (!Array.isArray(files)) { + files = [files]; + } - if ( - !CodebergFileSystem.kicad_extensions.includes( - extension(file.name), - ) - ) { - continue; - } + for (const file of files) { + if (!file.name || !file.git_url || file.type !== "file") { + continue; + } - files_to_urls.set(file.name, new URL(file.git_url)); + if ( + !CodebergFileSystem.kicad_extensions.includes( + extension(file.name), + ) + ) { + continue; } + + files_to_urls.set(file.name, new URL(file.git_url)); } if (files_to_urls.size == 0) { @@ -64,11 +61,11 @@ export class CodebergFileSystem extends VirtualFileSystem { return new CodebergFileSystem(files_to_urls); } - override *list(): Generator { + *list(): Generator { yield* this.files_to_urls.keys(); } - override async get(name: string): Promise { + async get(name: string) { const url = this.files_to_urls.get(name); if (!url) { throw new Error(`File ${name} not found.`); @@ -87,11 +84,11 @@ export class CodebergFileSystem extends VirtualFileSystem { return file; } - override has(name: string): Promise { + async has(name: string) { return Promise.resolve(this.files_to_urls.has(name)); } - override async download(name: string): Promise { + async download(name: string) { initiate_download(await this.get(name)); } } diff --git a/src/kicanvas/services/github-vfs.ts b/src/kicanvas/services/github-vfs.ts index c7c764bd..8c7a7279 100644 --- a/src/kicanvas/services/github-vfs.ts +++ b/src/kicanvas/services/github-vfs.ts @@ -4,116 +4,121 @@ Full text available at: https://opensource.org/licenses/MIT */ -import { initiate_download } from "../../base/dom/download"; -import { basename, dirname, extension } from "../../base/paths"; -import { GitHub, GitHubUserContent } from "./github"; -import { VirtualFileSystem } from "./vfs"; +import { + basename, + dirname, + extension, + normalize_join, + based_on, +} from "../../base/paths"; +import { GitHub, GitHubUserContent, type GitHubURLInfo } from "./github"; +import { FileSystemBase, type FileEntry } from "./vfs"; -const kicad_extensions = ["kicad_pcb", "kicad_pro", "kicad_sch"]; const gh_user_content = new GitHubUserContent(); const gh = new GitHub(); /** * Virtual file system for GitHub. */ -export class GitHubFileSystem extends VirtualFileSystem { - constructor(private files_to_urls: Map) { +export class GitHubFileSystem extends FileSystemBase { + private download_urls: Map; + + constructor( + url: string | URL, + private gh_repo: GitHubURLInfo, + private single_file = false, + ) { super(); + this.download_urls = new Map(); + + // try using `raw.github` directly for single file + if (single_file) { + // Handles URLs like this: + // https://github.com/wntrblm/Helium/blob/main/hardware/board/board.kicad_sch + // In single-file mode, just store file basename + const guc_url = gh_user_content.convert_url(url); + const name = basename(guc_url); + this.download_urls.set(name, guc_url); + } } - public static async fromURLs( - ...urls: (string | URL)[] - ): Promise { - // Handles URLs like this: - // https://github.com/wntrblm/Helium/blob/main/hardware/board/board.kicad_sch - - const files_to_urls = new Map(); - - for (const url of urls) { - const info = GitHub.parse_url(url); - - if (!info || !info.owner || !info.repo) { - continue; - } - - // Link to the root of a repo, treat it as tree using HEAD - if (info.type == "root") { - info.ref = "HEAD"; - info.type = "tree"; - } + override async load_file(path: string): Promise { + const download_url = this.download_urls.get(path); + if (!download_url) { + throw new Error(`File ${path} not found!`); + } - // Link to a single file. - if (info.type == "blob") { - if ( - ["kicad_sch", "kicad_pcb"].includes(extension(info.path!)) - ) { - const guc_url = gh_user_content.convert_url(url); - const name = basename(guc_url); - files_to_urls.set(name, guc_url); - } else { - // Link to non-kicad file, try using the containing directory. - info.type = "tree"; - info.path = dirname(info.path!); - } - } + return await gh_user_content.get(download_url); + } - // Link to a directory. - if (info.type == "tree") { - // Get a list of files in the directory. - const gh_file_list = (await gh.repos_contents( - info.owner, - info.repo, - info.path ?? "", - info.ref, - )) as Record[]; - - for (const gh_file of gh_file_list) { - const name = gh_file["name"]; - const download_url = gh_file["download_url"]; - if ( - !name || - !download_url || - !kicad_extensions.includes(extension(name)) - ) { - continue; - } - - files_to_urls.set(name, download_url); - } - } + override async enumerate(cur_dir: string): Promise { + if (this.single_file) { + // single file, return all files directly + return Array.from(this.download_urls.keys()).map((v) => ({ + type: "file", + path: v, + })); } - if (files_to_urls.size == 0) { - // no valid URL and files, return null. - return null; + const base_dir = this.gh_repo.path ?? ""; + const full_path = normalize_join(base_dir, cur_dir); + + const contents = await gh.repos_contents( + this.gh_repo.owner, + this.gh_repo.repo, + full_path, + this.gh_repo.ref, + ); + + const result: FileEntry[] = []; + for (const it of contents) { + if (it.type === "file" && GitHubFileSystem.is_kicad_file(it.name)) { + const file_path = based_on(base_dir, it.path); + + this.download_urls.set(file_path, new URL(it.download_url)); + + result.push({ + type: "file", + path: file_path, + }); + } else if (it.type === "dir") { + result.push({ + type: "directory", + path: based_on(base_dir, it.path), + }); + } } - return new GitHubFileSystem(files_to_urls); - } - - public override *list() { - yield* this.files_to_urls.keys(); + return result; } - public override get(name: string): Promise { - const url = this.files_to_urls.get(name); + public static async fromURLs( + url: string | URL, + ): Promise { + const info = GitHub.parse_url(url); - if (!url) { - throw new Error(`File ${name} not found!`); + if (!info) { + return null; } - return gh_user_content.get(url); - } + // Link to the root of a repo, treat it as tree using HEAD + if (info.type == "root") { + info.ref = "HEAD"; + info.type = "tree"; + } - public override has(name: string) { - return Promise.resolve(this.files_to_urls.has(name)); - } + // If it's one file just load one file. + let single_file = false; + if (info.type === "blob") { + if (["kicad_sch", "kicad_pcb"].includes(extension(info.path!))) { + single_file = true; + } else { + // Link to non-kicad file, try using the containing directory. + info.type = "tree"; + info.path = dirname(info.path!); + } + } - public override async download(name: string) { - // Note: we can't just use the GitHub URL to download since the anchor - // tag method used by initiate_download() only works for same-origin - // or data: urls, so this actually fetch()s the file and then initiates - // the download. - initiate_download(await this.get(name)); + return new GitHubFileSystem(url, info, single_file); } } diff --git a/src/kicanvas/services/github.ts b/src/kicanvas/services/github.ts index bd4b0791..84031e80 100644 --- a/src/kicanvas/services/github.ts +++ b/src/kicanvas/services/github.ts @@ -5,8 +5,29 @@ */ import { basename } from "../../base/paths"; +import { is_array } from "../../base/types"; import { request_error_handler } from "./api-error"; +export class GitHubURLInfo { + owner: string; + repo: string; + type: string; + ref?: string; + path?: string; +} + +export class GithubContentResponse { + download_url: string; + git_url: string; + html_url: string; + name: string; + path: string; + sha: string; + size: number; + type: string; + url: string; +} + export class GitHub { static readonly host_name = "github.com"; static readonly html_base_url = "https://github.com"; @@ -28,7 +49,7 @@ export class GitHub { /** * Parse an html (user-facing) URL */ - static parse_url(url: string | URL) { + static parse_url(url: string | URL): GitHubURLInfo | null { url = new URL(url, GitHub.html_base_url); if (url.hostname != GitHub.host_name) { return null; @@ -41,6 +62,9 @@ export class GitHub { } const [, owner, repo, ...parts] = path_parts; + if (!owner || !repo) { + return null; + } let type; let ref; @@ -56,6 +80,10 @@ export class GitHub { type = "root"; } + if (!type) { + return null; + } + return { owner: owner, repo: repo, @@ -111,9 +139,18 @@ export class GitHub { path: string, ref?: string, ) { - return await this.request(`repos/${owner}/${repo}/contents/${path}`, { - ref: ref ?? "", - }); + // https://docs.github.com/en/rest/repos/contents + // /repos/{owner}/{repo}/contents/{path} + const result = await this.request( + `repos/${owner}/${repo}/contents/${path}`, + { + ref: ref ?? "", + }, + ); + + return is_array(result) + ? (result as GithubContentResponse[]) + : [result as GithubContentResponse]; } } diff --git a/src/kicanvas/services/vfs.ts b/src/kicanvas/services/vfs.ts index 2348ada8..e8f3f00d 100644 --- a/src/kicanvas/services/vfs.ts +++ b/src/kicanvas/services/vfs.ts @@ -5,46 +5,245 @@ */ import { initiate_download } from "../../base/dom/download"; -import { basename } from "../../base/paths"; +import { + based_on, + basename, + dirname, + extension, + normalize_join, +} from "../../base/paths"; /** - * Virtual file system abstract class. + * Virtual file system interface. * * This is the interface used by to find and load files. * It's implemented using Drag and Drop and GitHub to provide a common interface * for interacting and loading files. */ -export abstract class VirtualFileSystem { - public abstract list(): Generator; - public abstract get(name: string): Promise; - public abstract has(name: string): Promise; - public abstract download(name: string): Promise; - - public *list_matches(r: RegExp) { - for (const filename of this.list()) { - if (filename.match(r)) { - yield filename; +export interface IFileSystem { + /** List all files */ + list(): Generator; + + /** Initialize it */ + setup(): Promise; + + /** Get a file */ + get(path: string): Promise; + + /** Return true if current file list has `path` */ + has(path: string): Promise; + + /** Download a file from the file system */ + download(name: string): Promise; +} + +/** + * File entry, directory or file + */ +export class FileEntry { + path: string; + type: "file" | "directory"; +} + +/** + * File entry, additional type for mark visited items + */ +class FileEntryCache { + path: string; + type: "file" | "directory" | "visited-directory"; +} + +/** + * File system base + */ +export abstract class FileSystemBase implements IFileSystem { + // path -> entry + // e.g. + // + root.kicad_pcb + // + subdir/ + // + qwq1.kicad_sch + // + qwq2.kicad_sch + // stored as + // root.kicad_pcb -> { name: "root.kicad_pcb", type: "file" } + // subdir -> { name: "subdir", type: "directory" } + // subdir/qwq1.kicad_sch -> { name: "subdir/qwq1.kicad_sch", type: "file" } + // subdir/qwq2.kicad_sch -> { name: "subdir/qwq2.kicad_sch", type: "file" } + private entries: Map; + + constructor(entries: Map = new Map()) { + this.entries = entries; + } + + *list() { + for (const [path, entry] of this.entries) { + if (entry.type === "file") { + yield path; + } + } + } + + async setup() { + // load all entries on root directory + await this.walk(""); + } + + async get(name: string) { + if (!(await this.has(name))) { + throw new Error(`File ${name} not found`); + } + + return await this.load_file(name); + } + + async has(name: string) { + const dir = dirname(name); + if (!this.entries.has(dir) && !this.entries.has(name)) { + return false; + } + + // load entries on current directory + await this.walk(dir); + + // check if the file exists and is a file + const obj = this.entries.get(name); + return !!obj && obj.type === "file"; + } + + async download(name: string) { + initiate_download(await this.get(name)); + } + + /** + * Return true if a file has extension name `.kicad_pcb`, `.kicad_prj` or `.kicad_sch` + */ + protected static is_kicad_file(name: string): boolean { + const exts = ["kicad_pcb", "kicad_pro", "kicad_sch"]; + + return exts.includes(extension(name)); + } + + /** + * Walk through directories and update `this.entries` + */ + private async walk(dir: string): Promise { + if (this.entries.get(dir)?.type === "visited-directory") { + // visited directory, skip it. + return; + } + + const entries = await this.enumerate(dir); + this.entries.set(dir, { path: dir, type: "visited-directory" }); + + for (const it of entries) { + if (it.type === "file" && !FileSystemBase.is_kicad_file(it.path)) { + continue; + } + this.entries.set(it.path, it); + } + } + + /** + * Load file from implementation-specific source + */ + protected abstract load_file(path: string): Promise; + + /** + * Enumerate files at `base_dir`. (default: empty) + */ + protected abstract enumerate(base_dir: string): Promise; +} + +/** + * Merge two virtual file systems into one + */ +export class MergedFileSystem implements IFileSystem { + private fs_list: IFileSystem[]; + + constructor(fs: (IFileSystem | null)[]) { + this.fs_list = fs.filter((f) => f !== null); + } + + *list() { + for (const fs of this.fs_list) { + yield* fs.list(); + } + } + + async setup() { + for (const fs of this.fs_list) { + await fs.setup(); + } + } + + async has(name: string): Promise { + for (const fs of this.fs_list) { + if (await fs.has(name)) { + return true; } } + + return false; } - public *list_ext(ext: string) { - if (!ext.startsWith(".")) { - ext = `.${ext}`; + async get(name: string): Promise { + for (const fs of this.fs_list) { + if (await fs.has(name)) { + return await fs.get(name); + } } - for (const filename of this.list()) { - if (filename.endsWith(ext)) { - yield filename; + throw new Error(`File ${name} not found`); + } + + async download(name: string) { + for (const fs of this.fs_list) { + if (await fs.has(name)) { + return await fs.download(name); } } + + throw new Error(`File ${name} not found`); + } +} + +/** + * Local file system base class, with a file list provided by the constructor. + */ +export class LocalFileSystemBase extends FileSystemBase { + constructor(private file_list: Map) { + super(LocalFileSystemBase.into_entries(file_list)); + } + + async load_file(path: string): Promise { + const file = this.file_list.get(path); + + if (!file) { + throw new Error(`File ${path} not found!`); + } + + return file; + } + + async enumerate(base_dir: string): Promise { + // All files are already provided by the constructor. + return []; + } + + private static into_entries(files: Map) { + const result = new Map(); + + for (const path of files.keys()) { + result.set(path, { path: path, type: "file" }); + } + + return result; } } /** * Virtual file system for URLs via Fetch */ -export class FetchFileSystem extends VirtualFileSystem { +export class FetchFileSystem extends FileSystemBase { private urls: Map = new Map(); private resolver!: (name: string) => URL; @@ -81,19 +280,11 @@ export class FetchFileSystem extends VirtualFileSystem { } } - public override *list() { - yield* this.urls.keys(); - } - - public override async has(name: string) { - return Promise.resolve(this.urls.has(name)); - } - - public override async get(name: string): Promise { - const url = this.#resolve(name); + async load_file(path: string): Promise { + const url = this.#resolve(path); if (!url) { - throw new Error(`File ${name} not found!`); + throw new Error(`File ${path} not found!`); } const request = new Request(url, { method: "GET" }); @@ -107,24 +298,23 @@ export class FetchFileSystem extends VirtualFileSystem { const blob = await response.blob(); - return new File([blob], name); + return new File([blob], path); } - public async download(name: string) { - initiate_download(await this.get(name)); + async enumerate(base_dir: string): Promise { + return Array.from(this.urls.keys()).map((path) => ({ + path, + type: "file", + })); } } /** * Virtual file system for HTML drag and drop (DataTransfer) */ -export class DragAndDropFileSystem extends VirtualFileSystem { - constructor(private items: FileSystemFileEntry[]) { - super(); - } - +export class DragAndDropFileSystem extends LocalFileSystemBase { static async fromDataTransfer(dt: DataTransfer) { - let items: FileSystemEntry[] = []; + const items: FileSystemEntry[] = []; // Pluck items out as webkit entries (either FileSystemFileEntry or // FileSystemDirectoryEntry) @@ -135,145 +325,91 @@ export class DragAndDropFileSystem extends VirtualFileSystem { } } - // If it's just one directory then open it and set all of our items - // to its contents. - if (items.length == 1 && items[0]?.isDirectory) { - const reader = ( - items[0] as FileSystemDirectoryEntry - ).createReader(); - - items = []; - - await new Promise((resolve, reject) => { - reader.readEntries((entries) => { - for (const entry of entries) { - if (!entry.isFile) { - continue; - } - items.push(entry); - } - resolve(true); - }, reject); - }); + // walk through directories and collect all file entries + const files = await DragAndDropFileSystem.walk(items); + + // load kicad files + const file_map = new Map(); + for (const entry of files) { + if ( + entry.isFile && + DragAndDropFileSystem.is_kicad_file(entry.name) + ) { + const file = await DragAndDropFileSystem.load(entry); + file_map.set(normalize_join(entry.fullPath), file); + } } - return new DragAndDropFileSystem(items as FileSystemFileEntry[]); - } + // TODO: more than one kicad_pro loaded??? - public override *list() { - for (const entry of this.items) { - yield entry.name; - } - } + // deduce the common base directory + const lcp = DragAndDropFileSystem.lcp(Array.from(file_map.keys())); + const res = new Map( + [...file_map].map(([p, f]) => [based_on(lcp, p), f]), + ); - public override async has(name: string): Promise { - for (const entry of this.items) { - if (entry.name == name) { - return true; - } - } - return false; + return new DragAndDropFileSystem(res); } - public override async get(name: string): Promise { - let file_entry: FileSystemFileEntry | null = null; - for (const entry of this.items) { - if (entry.name == name) { - file_entry = entry; - break; + private static lcp(str: string[]): string { + const beg = str[0] ?? ""; + return str.reduce((common, path) => { + let i = 0; + + while ( + i < common.length && + i < path.length && + common[i] === path[i] + ) { + i += 1; } - } - if (file_entry == null) { - throw new Error(`File ${name} not found!`); - } + return common.slice(0, i); + }, beg); + } + private static async load(entry: FileSystemFileEntry): Promise { return await new Promise((resolve, reject) => { - file_entry!.file(resolve, reject); + entry.file(resolve, reject); }); } - public async download(name: string) { - initiate_download(await this.get(name)); - } -} - -/** - * Virtual file system for local files - */ -export class LocalFileSystem extends VirtualFileSystem { - constructor(private files: File[]) { - super(); - } - - override *list() { - for (const entry of this.files) { - yield entry.name; - } - } - - override async has(name: string): Promise { - return this.files.find((f) => f.name == name) !== undefined; - } - - override async get(name: string): Promise { - const file = this.files.find((f) => f.name == name); - if (file) { - return file; - } else { - throw new Error(`File ${name} not found`); + private static async walk(items: FileSystemEntry[]) { + const files: FileSystemFileEntry[] = []; + + while (items.length > 0) { + const item = items.pop()!; + if (item.isFile) { + files.push(item as FileSystemFileEntry); + } else if (item.isDirectory) { + const reader = ( + item as FileSystemDirectoryEntry + ).createReader(); + + await new Promise((resolve, reject) => { + reader.readEntries((entries) => { + for (const entry of entries) { + if (entry.isFile) { + files.push(entry as FileSystemFileEntry); + } else if (entry.isDirectory) { + items.push(entry); + } + } + resolve(true); + }, reject); + }); + } } - } - override async download(name: string) { - initiate_download(await this.get(name)); + return files; } } /** - * Merge two virtual file systems into one + * Virtual file system for local files */ -export class MergedFileSystem extends VirtualFileSystem { - private fs_list: VirtualFileSystem[]; - - constructor(fs: (VirtualFileSystem | null)[]) { - super(); - this.fs_list = fs.filter((f) => f !== null); - } - - override *list() { - for (const fs of this.fs_list) { - yield* fs.list(); - } - } - - override async has(name: string): Promise { - for (const fs of this.fs_list) { - if (await fs.has(name)) { - return true; - } - } - - return false; - } - - override async get(name: string): Promise { - for (const fs of this.fs_list) { - if (await fs.has(name)) { - return await fs.get(name); - } - } - - throw new Error(`File ${name} not found`); - } - - override async download(name: string) { - for (const fs of this.fs_list) { - if (await fs.has(name)) { - return await fs.download(name); - } - } - - throw new Error(`File ${name} not found`); +export class LocalFileSystem extends LocalFileSystemBase { + constructor(files: File[]) { + super(new Map(files.map((f) => [f.name, f]))); } }