Skip to content

Commit df8a735

Browse files
authored
add: support the codeberg (#173)
1 parent c82173a commit df8a735

7 files changed

Lines changed: 324 additions & 50 deletions

File tree

‎src/base/base64.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
Copyright (c) 2025 Xiang Yang.
3+
Published under the standard MIT License.
4+
Full text available at: https://opensource.org/licenses/MIT
5+
*/
6+
export class BaseDecodeError extends Error {
7+
constructor(message: string) {
8+
super(message);
9+
}
10+
}
11+
12+
export function base64_decode(str: string): string {
13+
try {
14+
return atob(str);
15+
} catch (error) {
16+
throw new BaseDecodeError("Failed to decode base64 string: " + error);
17+
}
18+
}

‎src/kicanvas/elements/kicanvas-shell.ts‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import { CSS, attribute, html, query } from "../../base/web-components";
1111
import { KCUIElement, KCUIIconElement } from "../../kc-ui";
1212
import { sprites_url } from "../icons/sprites";
1313
import { Project } from "../project";
14-
import { GitHub } from "../services/github";
1514
import { GitHubFileSystem } from "../services/github-vfs";
15+
import { CodebergFileSystem } from "../services/codeberg-vfs";
1616
import { FetchFileSystem, type VirtualFileSystem } from "../services/vfs";
1717
import { KCBoardAppElement } from "./kc-board/app";
1818
import { KCSchematicAppElement } from "./kc-schematic/app";
@@ -82,7 +82,11 @@ class KiCanvasShellElement extends KCUIElement {
8282

8383
override initialContentCallback() {
8484
const url_params = new URLSearchParams(document.location.search);
85-
const github_paths = url_params.getAll("github");
85+
86+
const urls = [
87+
...url_params.getAll("github"),
88+
...url_params.getAll("repo"),
89+
];
8690

8791
later(async () => {
8892
if (this.src) {
@@ -91,8 +95,12 @@ class KiCanvasShellElement extends KCUIElement {
9195
return;
9296
}
9397

94-
if (github_paths.length) {
95-
const vfs = await GitHubFileSystem.fromURLs(...github_paths);
98+
if (urls.length) {
99+
const vfs = await this.load_repo(...urls);
100+
if (!vfs) {
101+
return;
102+
}
103+
96104
await this.setup_project(vfs);
97105
return;
98106
}
@@ -104,15 +112,18 @@ class KiCanvasShellElement extends KCUIElement {
104112

105113
this.link_input.addEventListener("input", async (e) => {
106114
const link = this.link_input.value;
107-
if (!GitHub.parse_url(link)) {
115+
const vfs = await this.load_repo(link);
116+
117+
if (!vfs) {
118+
// TODO: show error message: invaild URL
119+
console.error(`Invalid URL: ${link}`);
108120
return;
109121
}
110122

111-
const vfs = await GitHubFileSystem.fromURLs(link);
112123
await this.setup_project(vfs);
113124

114125
const location = new URL(window.location.href);
115-
location.searchParams.set("github", link);
126+
location.searchParams.set("repo", link);
116127
window.history.pushState(null, "", location);
117128
});
118129

@@ -123,6 +134,15 @@ class KiCanvasShellElement extends KCUIElement {
123134
});
124135
}
125136

137+
private async load_repo(
138+
...url: string[]
139+
): Promise<VirtualFileSystem | null> {
140+
return (
141+
(await GitHubFileSystem.fromURLs(...url)) ??
142+
(await CodebergFileSystem.fromURLs(...url))
143+
);
144+
}
145+
126146
private async setup_project(vfs: VirtualFileSystem) {
127147
this.loaded = false;
128148
this.loading = true;
@@ -174,7 +194,7 @@ class KiCanvasShellElement extends KCUIElement {
174194
<input
175195
name="link"
176196
type="text"
177-
placeholder="Paste a GitHub link..."
197+
placeholder="Paste a GitHub/Codeberg link..."
178198
autofocus />
179199
<p>
180200
or drag & drop your KiCad files, or<button

‎src/kicanvas/services/api-error.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
Copyright (c) 2025 Xiang Yang.
3+
Published under the standard MIT License.
4+
Full text available at: https://opensource.org/licenses/MIT
5+
*/
6+
export class BaseAPIError extends Error {
7+
constructor(
8+
public override name: string,
9+
public url: string,
10+
public description: string,
11+
public response?: Response,
12+
) {
13+
super(`API Error: ${name}: ${url}: ${description}`);
14+
}
15+
}
16+
17+
export class UnknownError extends BaseAPIError {
18+
constructor(url: string, description: string, response: Response) {
19+
super(`UnknownError`, url, description, response);
20+
}
21+
}
22+
23+
export class NotFoundError extends BaseAPIError {
24+
constructor(url: string, response: Response) {
25+
super(`NotFoundError`, url, "not found", response);
26+
}
27+
}
28+
29+
export class ForbiddenError extends BaseAPIError {
30+
constructor(url: string, response: Response) {
31+
super(`ForbiddenError`, url, "forbidden", response);
32+
}
33+
}
34+
35+
export async function request_error_handler(response: Response) {
36+
switch (response.status) {
37+
case 200:
38+
return;
39+
case 403:
40+
throw new ForbiddenError(response.url, response);
41+
case 404:
42+
throw new NotFoundError(response.url, response);
43+
case 500:
44+
throw new UnknownError(
45+
response.url,
46+
await response.text(),
47+
response,
48+
);
49+
}
50+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
Copyright (c) 2025 Xiang Yang.
3+
Published under the standard MIT License.
4+
Full text available at: https://opensource.org/licenses/MIT
5+
*/
6+
import { base64_decode } from "../../base/base64";
7+
import { initiate_download } from "../../base/dom/download";
8+
import { extension } from "../../base/paths";
9+
import { Codeberg, GetBlobResponse, RepoContentResponse } from "./codeberg";
10+
import { VirtualFileSystem } from "./vfs";
11+
12+
export class CodebergFileSystem extends VirtualFileSystem {
13+
static readonly kicad_extensions = ["kicad_pcb", "kicad_pro", "kicad_sch"];
14+
15+
constructor(private files_to_urls: Map<string, URL>) {
16+
super();
17+
}
18+
19+
public static async fromURLs(
20+
...urls: (string | URL)[]
21+
): Promise<CodebergFileSystem | null> {
22+
const files_to_urls = new Map<string, URL>();
23+
24+
for (const url of urls) {
25+
const info = Codeberg.parse_url(url);
26+
if (!info) {
27+
continue;
28+
}
29+
30+
// API:
31+
// https://codeberg.org/api/swagger#/repository/repoGetContents
32+
const api_url = `repos/${info.owner}/${info.repo}/contents/${info.path}`;
33+
34+
let files = await Codeberg.request_json<
35+
RepoContentResponse | RepoContentResponse[]
36+
>(api_url);
37+
38+
if (!Array.isArray(files)) {
39+
files = [files];
40+
}
41+
42+
for (const file of files) {
43+
if (!file.name || !file.git_url || file.type !== "file") {
44+
continue;
45+
}
46+
47+
if (
48+
!CodebergFileSystem.kicad_extensions.includes(
49+
extension(file.name),
50+
)
51+
) {
52+
continue;
53+
}
54+
55+
files_to_urls.set(file.name, new URL(file.git_url));
56+
}
57+
}
58+
59+
if (files_to_urls.size == 0) {
60+
// no valid URL and files, return null.
61+
return null;
62+
}
63+
64+
return new CodebergFileSystem(files_to_urls);
65+
}
66+
67+
override *list(): Generator<string> {
68+
yield* this.files_to_urls.keys();
69+
}
70+
71+
override async get(name: string): Promise<File> {
72+
const url = this.files_to_urls.get(name);
73+
if (!url) {
74+
throw new Error(`File ${name} not found.`);
75+
}
76+
77+
// API: https://codeberg.org/api/swagger#/repository/GetBlob
78+
const blob = await Codeberg.request_json<GetBlobResponse>(url.pathname);
79+
80+
if (blob.encoding !== "base64") {
81+
throw new Error(`Unsupported encoding: ${blob.encoding}`);
82+
}
83+
84+
const content = base64_decode(blob.content);
85+
const file = new File([content], name);
86+
87+
return file;
88+
}
89+
90+
override has(name: string): Promise<boolean> {
91+
return Promise.resolve(this.files_to_urls.has(name));
92+
}
93+
94+
override async download(name: string): Promise<void> {
95+
initiate_download(await this.get(name));
96+
}
97+
}

‎src/kicanvas/services/codeberg.ts‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
Copyright (c) 2025 Xiang Yang.
3+
Published under the standard MIT License.
4+
Full text available at: https://opensource.org/licenses/MIT
5+
*/
6+
import { request_error_handler } from "./api-error";
7+
8+
export class RepoContentResponse {
9+
name: string;
10+
path: string;
11+
type: string;
12+
sha: string;
13+
url: string;
14+
git_url: string;
15+
}
16+
17+
export class GetBlobResponse {
18+
content: string;
19+
encoding: string;
20+
sha: string;
21+
size: number;
22+
url: string;
23+
}
24+
25+
export class Codeberg {
26+
static readonly host_name = "codeberg.org";
27+
static readonly html_base_url = "https://codeberg.org/";
28+
static readonly base_url = "https://codeberg.org/api/v1/";
29+
static readonly accept_header = "application/json";
30+
31+
static parse_url(url: string | URL) {
32+
url = new URL(url, Codeberg.html_base_url);
33+
if (url.hostname !== Codeberg.host_name) {
34+
return null;
35+
}
36+
37+
const path_parts = url.pathname.split("/");
38+
39+
if (path_parts.length < 3) {
40+
return null;
41+
}
42+
43+
const [, owner, repo, ...parts] = path_parts;
44+
45+
let ref, path;
46+
if (parts.length > 0) {
47+
// owner/repo/src/<commit|branch>/<ref>/<rel_path>
48+
const typ = parts.shift();
49+
if (typ !== "src") {
50+
return null;
51+
}
52+
53+
const ref_type = parts.shift();
54+
if (ref_type !== "commit" && ref_type !== "branch") {
55+
return null;
56+
}
57+
58+
ref = parts.shift();
59+
if (!ref) {
60+
return null;
61+
}
62+
63+
path = parts.join("/");
64+
} else {
65+
// owner/repo
66+
ref = "main";
67+
path = ".";
68+
}
69+
70+
return {
71+
owner: owner,
72+
repo: repo,
73+
ref: ref,
74+
path: path,
75+
};
76+
}
77+
78+
static async request(
79+
path: string,
80+
params?: Record<string, string>,
81+
data?: unknown,
82+
): Promise<string> {
83+
const url = new URL(path, Codeberg.base_url);
84+
85+
if (params) {
86+
const url_params = new URLSearchParams(params).toString();
87+
url.search = `?${url_params}`;
88+
}
89+
90+
const request = new Request(url, {
91+
method: data ? "POST" : "GET",
92+
headers: {
93+
Accept: Codeberg.accept_header,
94+
},
95+
body: data ? JSON.stringify(data) : undefined,
96+
});
97+
98+
const response = await fetch(request);
99+
await request_error_handler(response);
100+
101+
return await response.text();
102+
}
103+
104+
static async request_json<T>(
105+
path: string,
106+
params?: Record<string, string>,
107+
data?: unknown,
108+
): Promise<T> {
109+
const text = await Codeberg.request(path, params, data);
110+
try {
111+
return JSON.parse(text) as T;
112+
} catch (error) {
113+
throw new Error("Failed to parse JSON response: " + error);
114+
}
115+
}
116+
}

‎src/kicanvas/services/github-vfs.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ export class GitHubFileSystem extends VirtualFileSystem {
2121
super();
2222
}
2323

24-
public static async fromURLs(...urls: (string | URL)[]) {
24+
public static async fromURLs(
25+
...urls: (string | URL)[]
26+
): Promise<GitHubFileSystem | null> {
2527
// Handles URLs like this:
2628
// https://github.com/wntrblm/Helium/blob/main/hardware/board/board.kicad_sch
2729

@@ -81,6 +83,11 @@ export class GitHubFileSystem extends VirtualFileSystem {
8183
}
8284
}
8385

86+
if (files_to_urls.size == 0) {
87+
// no valid URL and files, return null.
88+
return null;
89+
}
90+
8491
return new GitHubFileSystem(files_to_urls);
8592
}
8693

0 commit comments

Comments
 (0)