Skip to content

Commit 3dfc520

Browse files
authored
Merge branch 'master' into fix-draft
2 parents 04b6bbc + 8b157ac commit 3dfc520

File tree

11 files changed

+309
-16
lines changed

11 files changed

+309
-16
lines changed

.github/helper/install_dependencies.sh

+3-3
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://pack
99

1010
sudo apt update
1111
sudo apt remove mysql-server mysql-client
12-
sudo apt install libcups2-dev redis mariadb-client-10.6
12+
sudo apt install libcups2-dev redis mariadb-client
1313

1414
install_wkhtmltopdf() {
15-
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb
16-
sudo apt install ./wkhtmltox_0.12.6-1.focal_amd64.deb
15+
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
16+
sudo apt install ./wkhtmltox_0.12.6.1-2.jammy_amd64.deb
1717
}
1818
install_wkhtmltopdf &

wiki/public/build.json

-4
This file was deleted.

wiki/public/js/wiki.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ window.Wiki = class Wiki {
3232
$(".doc-sidebar,.web-sidebar").on(
3333
"click",
3434
".collapsible",
35-
this.toggle_sidebar
35+
this.toggle_sidebar,
3636
);
3737

3838
$(".sidebar-item.active")
@@ -46,7 +46,7 @@ window.Wiki = class Wiki {
4646

4747
set_last_updated_date() {
4848
const lastUpdatedDate = frappe.datetime.prettyDate(
49-
$(".user-contributions").data("date")
49+
$(".user-contributions").data("date"),
5050
);
5151
$(".user-contributions").append(`last updated ${lastUpdatedDate}`);
5252
}
@@ -57,7 +57,7 @@ window.Wiki = class Wiki {
5757
const src = $(".navbar-brand img").attr("src");
5858
if (
5959
!["{{ light_mode_logo }}", "{{ dark_mode_logo }}", "None", ""].includes(
60-
altSrc
60+
altSrc,
6161
)
6262
) {
6363
$(".navbar-brand img").attr("src", altSrc);
@@ -117,7 +117,7 @@ window.Wiki = class Wiki {
117117
$("pre code")
118118
.parent("pre")
119119
.prepend(
120-
`<button title="Copy Code" class="btn copy-btn" data-toggle="tooltip"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-clipboard"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg></button>`
120+
`<button title="Copy Code" class="btn copy-btn" data-toggle="tooltip"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-clipboard"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg></button>`,
121121
);
122122

123123
$(".copy-btn").on("click", function () {

wiki/wiki/doctype/wiki_space/wiki_space.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
],
7272
"index_web_pages_for_search": 1,
7373
"links": [],
74-
"modified": "2024-04-05 21:21:29.535486",
74+
"modified": "2024-12-11 15:27:44.629602",
7575
"modified_by": "Administrator",
7676
"module": "Wiki",
7777
"name": "Wiki Space",
@@ -105,5 +105,6 @@
105105
],
106106
"sort_field": "modified",
107107
"sort_order": "DESC",
108-
"states": []
108+
"states": [],
109+
"title_field": "route"
109110
}

wiki/wiki/report/__init__.py

Whitespace-only changes.

wiki/wiki/report/wiki_broken_links/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Copyright (c) 2024, Frappe and Contributors
2+
# See license.txt
3+
4+
from unittest.mock import patch
5+
6+
import frappe
7+
from frappe.tests.utils import FrappeTestCase
8+
9+
from wiki.wiki.report.wiki_broken_links.wiki_broken_links import execute, get_broken_links
10+
11+
WORKING_EXTERNAL_URL = "https://frappe.io"
12+
BROKEN_EXTERNAL_URL = "https://frappewiki.notavalidtld"
13+
BROKEN_IMG_URL = "https://img.notavalidtld/failed.jpeg"
14+
WORKING_INTERNAL_URL = "/api/method/ping"
15+
BROKEN_INTERNAL_URL = "/api/method/ring"
16+
17+
18+
def internal_to_external_urls(internal_url: str) -> str:
19+
if internal_url == WORKING_INTERNAL_URL:
20+
return WORKING_EXTERNAL_URL
21+
else:
22+
return BROKEN_EXTERNAL_URL
23+
24+
25+
TEST_MD_WITH_BROKEN_LINK = f"""
26+
## Hello
27+
28+
This is a test for a [broken link]({BROKEN_EXTERNAL_URL}).
29+
30+
This is a [valid link]({WORKING_EXTERNAL_URL}).
31+
And [this is a correct relative link]({WORKING_INTERNAL_URL}).
32+
And [this is an incorrect relative link]({BROKEN_INTERNAL_URL}).
33+
34+
This [hash link](#hash-link) should be ignored.
35+
36+
![Broken Image]({BROKEN_IMG_URL})
37+
"""
38+
39+
40+
class TestWikiBrokenLinkChecker(FrappeTestCase):
41+
def setUp(self):
42+
frappe.db.delete("Wiki Page")
43+
self.test_wiki_page = frappe.get_doc(
44+
{
45+
"doctype": "Wiki Page",
46+
"content": TEST_MD_WITH_BROKEN_LINK,
47+
"title": "My Wiki Page",
48+
"route": "test-wiki-page-route",
49+
}
50+
).insert()
51+
52+
self.test_wiki_space = frappe.get_doc({"doctype": "Wiki Space", "route": "test-ws-route"}).insert()
53+
54+
def test_returns_correct_broken_links(self):
55+
broken_links = get_broken_links(TEST_MD_WITH_BROKEN_LINK)
56+
self.assertEqual(len(broken_links), 2)
57+
58+
def test_wiki_broken_link_report(self):
59+
_, data = execute()
60+
self.assertEqual(len(data), 1)
61+
self.assertEqual(data[0]["broken_link"], BROKEN_EXTERNAL_URL)
62+
63+
def test_wiki_broken_link_report_with_wiki_space_filter(self):
64+
_, data = execute({"wiki_space": self.test_wiki_space.name})
65+
self.assertEqual(len(data), 0)
66+
67+
self.test_wiki_space.append(
68+
"wiki_sidebars", {"wiki_page": self.test_wiki_page, "parent_label": "Test Parent Label"}
69+
)
70+
self.test_wiki_space.save()
71+
72+
_, data = execute({"wiki_space": self.test_wiki_space.name})
73+
self.assertEqual(len(data), 1)
74+
self.assertEqual(data[0]["wiki_page"], self.test_wiki_page.name)
75+
self.assertEqual(data[0]["broken_link"], BROKEN_EXTERNAL_URL)
76+
77+
def test_wiki_broken_link_report_with_image_filter(self):
78+
_, data = execute({"check_images": 1})
79+
self.assertEqual(len(data), 2)
80+
self.assertEqual(data[0]["wiki_page"], self.test_wiki_page.name)
81+
self.assertEqual(data[0]["broken_link"], BROKEN_EXTERNAL_URL)
82+
83+
self.assertEqual(data[1]["wiki_page"], self.test_wiki_page.name)
84+
self.assertEqual(data[1]["broken_link"], BROKEN_IMG_URL)
85+
86+
@patch.object(frappe.utils.data, "get_url", side_effect=internal_to_external_urls)
87+
def test_wiki_broken_link_report_with_internal_links(self, _get_url):
88+
# patch the get_url to return valid/invalid external links instead
89+
# of internal links in test
90+
_, data = execute({"check_internal_links": 1})
91+
92+
self.assertEqual(len(data), 2)
93+
self.assertEqual(data[0]["wiki_page"], self.test_wiki_page.name)
94+
self.assertEqual(data[0]["broken_link"], BROKEN_EXTERNAL_URL)
95+
96+
self.assertEqual(data[1]["wiki_page"], self.test_wiki_page.name)
97+
self.assertEqual(data[1]["broken_link"], BROKEN_INTERNAL_URL)
98+
99+
def tearDown(self):
100+
frappe.db.rollback()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) 2024, Frappe and contributors
2+
// For license information, please see license.txt
3+
4+
frappe.query_reports["Wiki Broken Links"] = {
5+
filters: [
6+
{
7+
fieldname: "wiki_space",
8+
label: __("Wiki Space"),
9+
fieldtype: "Link",
10+
options: "Wiki Space",
11+
},
12+
{
13+
fieldname: "check_images",
14+
label: __("Include images?"),
15+
fieldtype: "Check",
16+
default: 1,
17+
},
18+
{
19+
fieldname: "check_internal_links",
20+
label: __("Include internal links?"),
21+
fieldtype: "Check",
22+
default: 0,
23+
},
24+
],
25+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"add_total_row": 0,
3+
"columns": [],
4+
"creation": "2024-12-11 14:43:18.799835",
5+
"disabled": 0,
6+
"docstatus": 0,
7+
"doctype": "Report",
8+
"filters": [],
9+
"idx": 0,
10+
"is_standard": "Yes",
11+
"letterhead": null,
12+
"modified": "2024-12-11 18:58:14.479423",
13+
"modified_by": "Administrator",
14+
"module": "Wiki",
15+
"name": "Wiki Broken Links",
16+
"owner": "Administrator",
17+
"prepared_report": 1,
18+
"ref_doctype": "Wiki Page",
19+
"report_name": "Wiki Broken Links",
20+
"report_type": "Script Report",
21+
"roles": [
22+
{
23+
"role": "System Manager"
24+
},
25+
{
26+
"role": "Wiki Approver"
27+
}
28+
],
29+
"timeout": 0
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Copyright (c) 2024, Frappe and contributors
2+
# For license information, please see license.txt
3+
4+
import frappe
5+
import requests
6+
from bs4 import BeautifulSoup
7+
from frappe import _
8+
9+
10+
def execute(filters: dict | None = None):
11+
"""Return columns and data for the report.
12+
13+
This is the main entry point for the report. It accepts the filters as a
14+
dictionary and should return columns and data. It is called by the framework
15+
every time the report is refreshed or a filter is updated.
16+
"""
17+
columns = get_columns()
18+
data = get_data(filters)
19+
20+
return columns, data
21+
22+
23+
def get_columns() -> list[dict]:
24+
"""Return columns for the report.
25+
26+
One field definition per column, just like a DocType field definition.
27+
"""
28+
return [
29+
{
30+
"label": _("Wiki Page"),
31+
"fieldname": "wiki_page",
32+
"fieldtype": "Link",
33+
"options": "Wiki Page",
34+
"width": 200,
35+
},
36+
{
37+
"label": _("Broken Link"),
38+
"fieldname": "broken_link",
39+
"fieldtype": "Data",
40+
"options": "URL",
41+
"width": 400,
42+
},
43+
]
44+
45+
46+
def get_data(filters: dict | None = None) -> list[list]:
47+
"""Return data for the report.
48+
49+
The report data is a list of rows, with each row being a list of cell values.
50+
"""
51+
data = []
52+
53+
wiki_pages = frappe.db.get_all("Wiki Page", fields=["name", "content"])
54+
55+
if filters and filters.get("wiki_space"):
56+
wiki_space = filters.get("wiki_space")
57+
wiki_pages = frappe.db.get_all(
58+
"Wiki Group Item",
59+
fields=["wiki_page as name", "wiki_page.content as content"],
60+
filters={"parent": wiki_space, "parenttype": "Wiki Space"},
61+
)
62+
63+
include_images = filters and bool(filters.get("check_images"))
64+
check_internal_links = filters and bool(filters.get("check_internal_links"))
65+
66+
for page in wiki_pages:
67+
broken_links_for_page = get_broken_links(page.content, include_images, check_internal_links)
68+
rows = [{"broken_link": link, "wiki_page": page["name"]} for link in broken_links_for_page]
69+
data.extend(rows)
70+
71+
return data
72+
73+
74+
def get_broken_links(
75+
md_content: str, include_images: bool = True, include_relative_urls: bool = False
76+
) -> list[str]:
77+
html = frappe.utils.md_to_html(md_content)
78+
soup = BeautifulSoup(html, "html.parser")
79+
80+
links = soup.find_all("a")
81+
if include_images:
82+
links += soup.find_all("img")
83+
84+
broken_links = []
85+
for el in links:
86+
url = el.attrs.get("href") or el.attrs.get("src")
87+
88+
if is_hash_link(url):
89+
continue
90+
91+
is_relative = is_relative_url(url)
92+
relative_url = None
93+
94+
if is_relative and not include_relative_urls:
95+
continue
96+
97+
if is_relative:
98+
relative_url = url
99+
url = frappe.utils.data.get_url(url) # absolute URL
100+
101+
is_broken = is_broken_link(url)
102+
if is_broken:
103+
if is_relative:
104+
broken_links.append(relative_url) # original URL
105+
else:
106+
broken_links.append(url)
107+
108+
return broken_links
109+
110+
111+
def is_relative_url(url: str) -> bool:
112+
return url.startswith("/")
113+
114+
115+
def is_hash_link(url: str) -> bool:
116+
return url.startswith("#")
117+
118+
119+
def is_broken_link(url: str) -> bool:
120+
try:
121+
status_code = get_request_status_code(url)
122+
if status_code >= 400:
123+
return True
124+
except Exception:
125+
return True
126+
127+
return False
128+
129+
130+
def get_request_status_code(url: str) -> int:
131+
response = requests.head(url, verify=False, timeout=5)
132+
return response.status_code

0 commit comments

Comments
 (0)