Skip to content

Commit 25d6ad1

Browse files
committed
fix: make plugin store repository links host-aware
Refs #76
1 parent ffd476f commit 25d6ad1

2 files changed

Lines changed: 130 additions & 36 deletions

File tree

pypluginstore.html

Lines changed: 68 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -696,51 +696,84 @@ <h1>PyPluginStore: Plugin Store</h1>
696696
return displayName || pluginName;
697697
}
698698

699-
function buildRepoUrl(author, repo) {
700-
const cleanAuthor = String(author || '').trim();
701-
const cleanRepo = String(repo || '').trim();
702-
const supportedHosts = ['github.com', 'gitlab.com', 'codeberg.org'];
703-
704-
function stripRepoUrl(url) {
705-
let stripped = String(url || '').trim().replace(/\/$/, '');
706-
const markers = [
707-
'/-/tree/',
708-
'/-/blob/',
709-
'/tree/',
710-
'/blob/',
711-
'/src/branch/',
712-
'/raw/branch/'
713-
];
714-
for (const marker of markers) {
715-
if (stripped.indexOf(marker) !== -1) {
716-
stripped = stripped.split(marker, 1)[0];
717-
break;
718-
}
699+
function stripRepoUrl(url) {
700+
let stripped = String(url || '').trim().replace(/\/$/, '');
701+
const markers = [
702+
'/-/tree/',
703+
'/-/blob/',
704+
'/tree/',
705+
'/blob/',
706+
'/src/branch/',
707+
'/raw/branch/'
708+
];
709+
for (const marker of markers) {
710+
if (stripped.indexOf(marker) !== -1) {
711+
stripped = stripped.split(marker, 1)[0];
712+
break;
719713
}
720-
return stripped.replace(/\.git$/, '');
721714
}
715+
return stripped.replace(/\.git$/, '');
716+
}
722717

723-
function encodePath(value) {
724-
return String(value || '').split('/').map(encodeURIComponent).join('/');
718+
function encodeRepoPath(pathParts) {
719+
return pathParts.map(part => encodeURIComponent(part)).join('/');
720+
}
721+
722+
function parseRepoReference(author, repo) {
723+
const cleanAuthor = String(author || '').trim();
724+
const cleanRepo = String(repo || '').trim().replace(/\.git$/, '');
725+
const fallbackHost = 'github.com';
726+
727+
if (cleanAuthor.startsWith('http://') || cleanAuthor.startsWith('https://')) {
728+
try {
729+
const strippedUrl = stripRepoUrl(cleanAuthor);
730+
const parsed = new URL(strippedUrl);
731+
const pathParts = parsed.pathname
732+
.replace(/^\/+|\/+$/g, '')
733+
.split('/')
734+
.filter(Boolean)
735+
.map(part => decodeURIComponent(part.replace(/\.git$/, '')));
736+
return { host: parsed.hostname.toLowerCase(), pathParts };
737+
} catch (error) {
738+
return { host: fallbackHost, pathParts: [cleanAuthor, cleanRepo].filter(Boolean) };
739+
}
725740
}
726741

727-
const sshMatch = cleanAuthor.match(/^git@([^:]+):(.+)$/);
728-
if (sshMatch && supportedHosts.indexOf(sshMatch[1].toLowerCase()) !== -1) {
729-
return stripRepoUrl('https://' + sshMatch[1].toLowerCase() + '/' + sshMatch[2]);
742+
const sshMatch = cleanAuthor.match(/^(?:[^@:/]+@)?([^:/]+):(.+)$/);
743+
if (sshMatch) {
744+
const host = sshMatch[1].toLowerCase();
745+
const pathParts = sshMatch[2].replace(/\.git$/, '').split('/').filter(Boolean);
746+
return { host, pathParts };
730747
}
731748

732-
if (cleanAuthor.startsWith('http://') || cleanAuthor.startsWith('https://')) {
733-
return stripRepoUrl(cleanAuthor);
749+
const authorParts = cleanAuthor.split('/').filter(Boolean);
750+
const firstAuthorPart = (authorParts[0] || '').toLowerCase();
751+
if (firstAuthorPart.indexOf('.') !== -1 && authorParts.length > 1) {
752+
const pathParts = authorParts.slice(1);
753+
if (cleanRepo) pathParts.push(cleanRepo);
754+
return { host: firstAuthorPart, pathParts };
734755
}
735756

736-
for (const host of supportedHosts) {
737-
if (cleanAuthor.toLowerCase() === host || cleanAuthor.toLowerCase().startsWith(host + '/')) {
738-
const path = cleanRepo ? cleanAuthor.replace(/\/$/, '') + '/' + cleanRepo : cleanAuthor;
739-
return stripRepoUrl('https://' + encodePath(path));
740-
}
757+
return { host: fallbackHost, pathParts: [cleanAuthor, cleanRepo].filter(Boolean) };
758+
}
759+
760+
function buildRepoUrl(author, repo) {
761+
const repoReference = parseRepoReference(author, repo);
762+
if (!repoReference.host || repoReference.pathParts.length === 0) {
763+
return '';
741764
}
765+
return stripRepoUrl('https://' + repoReference.host + '/' + encodeRepoPath(repoReference.pathParts));
766+
}
742767

743-
return 'https://github.com/' + encodeURIComponent(cleanAuthor) + '/' + encodeURIComponent(cleanRepo);
768+
function formatAuthorDisplay(author, repo) {
769+
const repoReference = parseRepoReference(author, repo);
770+
if (!repoReference.host || repoReference.pathParts.length === 0) {
771+
return String(author || '').trim();
772+
}
773+
const ownerPath = repoReference.pathParts.length > 1
774+
? repoReference.pathParts.slice(0, -1)
775+
: repoReference.pathParts;
776+
return repoReference.host + '/' + ownerPath.join('/');
744777
}
745778

746779
function renderPlugins(pluginData, installedList) {
@@ -844,7 +877,7 @@ <h1>PyPluginStore: Plugin Store</h1>
844877

845878
const meta = document.createElement('div');
846879
meta.className = 'card-meta';
847-
meta.appendChild(document.createTextNode('Author: ' + author));
880+
meta.appendChild(document.createTextNode('Author: ' + formatAuthorDisplay(author, repo)));
848881
meta.appendChild(document.createElement('br'));
849882
meta.appendChild(document.createTextNode('Repo: ' + repo + (branch ? ' (' + branch + ')' : '')));
850883

tests/test_ui_smoke.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,21 @@ def test_repo_url_builder_supports_codeberg_and_gitlab_hosts():
115115
if not node:
116116
pytest.skip("node is not installed")
117117

118-
function_source = extract_js_function(load_inline_script(), "buildRepoUrl")
118+
script = load_inline_script()
119+
function_source = "\n".join([
120+
extract_js_function(script, "stripRepoUrl"),
121+
extract_js_function(script, "encodeRepoPath"),
122+
extract_js_function(script, "parseRepoReference"),
123+
extract_js_function(script, "buildRepoUrl"),
124+
])
119125
cases = [
120126
["owner", "repo", "https://github.com/owner/repo"],
127+
["github.com/Hoog", "Domoticz-Stromer-plugin", "https://github.com/Hoog/Domoticz-Stromer-plugin"],
121128
["codeberg.org/Hoog", "Domoticz-Stromer-plugin", "https://codeberg.org/Hoog/Domoticz-Stromer-plugin"],
122129
["gitlab.com/r.boeters", "DomoticzSabNZBDPlugin", "https://gitlab.com/r.boeters/DomoticzSabNZBDPlugin"],
130+
["example.org/Team", "DomoticzPlugin", "https://example.org/Team/DomoticzPlugin"],
123131
["git@gitlab.com:r.boeters/DomoticzSabNZBDPlugin.git", "", "https://gitlab.com/r.boeters/DomoticzSabNZBDPlugin"],
132+
["git@example.org:Team/DomoticzPlugin.git", "", "https://example.org/Team/DomoticzPlugin"],
124133
["https://codeberg.org/Hoog/Domoticz-Stromer-plugin/src/branch/main", "", "https://codeberg.org/Hoog/Domoticz-Stromer-plugin"],
125134
["https://gitlab.com/r.boeters/DomoticzSabNZBDPlugin/-/tree/master", "", "https://gitlab.com/r.boeters/DomoticzSabNZBDPlugin"],
126135
]
@@ -149,6 +158,58 @@ def test_repo_url_builder_supports_codeberg_and_gitlab_hosts():
149158
assert result.returncode == 0, result.stderr
150159

151160

161+
def test_author_display_includes_repository_host_for_all_hosted_entries():
162+
node = shutil.which("node")
163+
if not node:
164+
pytest.skip("node is not installed")
165+
166+
script = load_inline_script()
167+
function_source = "\n".join([
168+
extract_js_function(script, "stripRepoUrl"),
169+
extract_js_function(script, "encodeRepoPath"),
170+
extract_js_function(script, "parseRepoReference"),
171+
extract_js_function(script, "formatAuthorDisplay"),
172+
])
173+
cases = [
174+
["Hoog", "Domoticz-Stromer-plugin", "github.com/Hoog"],
175+
["github.com/Hoog", "Domoticz-Stromer-plugin", "github.com/Hoog"],
176+
["codeberg.org/Hoog", "Domoticz-Stromer-plugin", "codeberg.org/Hoog"],
177+
["gitlab.com/r.boeters", "DomoticzSabNZBDPlugin", "gitlab.com/r.boeters"],
178+
["https://codeberg.org/Hoog/Domoticz-Stromer-plugin/src/branch/main", "", "codeberg.org/Hoog"],
179+
["git@gitlab.com:r.boeters/DomoticzSabNZBDPlugin.git", "", "gitlab.com/r.boeters"],
180+
]
181+
node_script = f"""
182+
{function_source}
183+
const cases = {json.dumps(cases)};
184+
for (const [author, repo, expected] of cases) {{
185+
const actual = formatAuthorDisplay(author, repo);
186+
if (actual !== expected) {{
187+
throw new Error(`${{author}}/${{repo}}: expected "${{expected}}", got "${{actual}}"`);
188+
}}
189+
}}
190+
"""
191+
192+
with tempfile.NamedTemporaryFile(suffix=".js", mode="w", delete=False) as f:
193+
f.write(node_script)
194+
temp_path = f.name
195+
try:
196+
result = subprocess.run(
197+
[node, temp_path],
198+
capture_output=True,
199+
text=True,
200+
)
201+
finally:
202+
os.remove(temp_path)
203+
assert result.returncode == 0, result.stderr
204+
205+
206+
def test_plugin_cards_use_formatted_author_display():
207+
script = load_inline_script()
208+
209+
assert "'Author: ' + formatAuthorDisplay(author, repo)" in script
210+
assert "'Author: ' + author" not in script
211+
212+
152213
def test_update_buttons_keep_shared_and_state_specific_classes():
153214
html = (REPO_ROOT / "pypluginstore.html").read_text()
154215

0 commit comments

Comments
 (0)