Skip to content

Commit 8a540f0

Browse files
committed
download favicons for menus that support icons
wip vendor/rewrite favicon dep update PKGBUILD
1 parent a3558f5 commit 8a540f0

File tree

12 files changed

+1009
-2
lines changed

12 files changed

+1009
-2
lines changed

TODO

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
favicon request content type: ["image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5"]

contrib/PKGBUILD

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ url="https://github.com/pvsr/qbpm"
88
license=('GPL-3.0-or-later')
99
sha512sums=('SKIP')
1010
arch=('any')
11-
depends=('python' 'python-click' 'python-xdg-base-dirs')
11+
depends=('python' 'python-click' 'python-xdg-base-dirs' 'python-httpx' 'python-pillow')
1212
makedepends=('git' 'python-build' 'python-installer' 'python-wheel' 'python-flit-core' 'scdoc')
1313
provides=('qbpm')
1414
source=("git+https://github.com/pvsr/qbpm")

flake.nix

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,14 @@
6060
packages = [
6161
pkgs.ruff
6262
pkgs.nixfmt-rfc-style
63+
pkgs.xdg-utils
6364
(pyprojectEnv (
6465
ps: with ps; [
6566
flit
6667
pytest
6768
mypy
6869
pylsp-mypy
70+
# types-pillow
6971
]
7072
))
7173
];

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ classifiers = [
1616
"Typing :: Typed",
1717
]
1818
requires-python = ">= 3.11"
19-
dependencies = ["click", "xdg-base-dirs"]
19+
dependencies = ["click", "xdg-base-dirs", "httpx", "pillow"]
2020

2121
[project.urls]
2222
homepage = "https://github.com/pvsr/qbpm"

qbpm.1.scd

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ appropriate \--basedir, or more conveniently using the qbpm launch and qbpm choo
8080
qbpm launch -n qb-dev --debug --json-logging
8181
```
8282

83+
*icon* [options] <profile> <icon>
84+
Install an icon to _profile_. _icon_ may be a url, a path to an image file,
85+
or, if --by-name is passed, the name of an xdg icon installed on your
86+
system. If _icon_ is a url, qbpm will fetch the page and attempt to find a
87+
suitable favicon.
88+
89+
Options:
90+
91+
*-n, --by-name*
92+
Interpret _icon_ as the name of an xdg icon file according to the
93+
freedesktop.org icon specification. Likely to only work on Linux.
94+
8395
*choose* [options]
8496
Open a menu to choose a qutebrowser profile to launch. On linux this defaults
8597
to dmenu or another compatible menu program such as rofi, and on macOS this
@@ -126,8 +138,24 @@ appropriate \--basedir, or more conveniently using the qbpm launch and qbpm choo
126138

127139
Peter Rice
128140

141+
<<<<<<< Conflict 1 of 1
142+
%%%%%%% Changes from base to side #1
143+
-Contribute at https://github.com/pvsr/qbpm
144+
+# CONTRIBUTE
145+
+
146+
+_https://github.com/pvsr/qbpm_
147+
+
148+
+_https://codeberg.org/pvsr/qbpm_
149+
+++++++ Contents of side #2
129150
# CONTRIBUTE
130151

131152
_https://github.com/pvsr/qbpm_
132153

133154
_https://codeberg.org/pvsr/qbpm_
155+
156+
# LICENSE
157+
158+
src/qbpm/favicon.py: MIT
159+
160+
all other code and qbpm as a whole: GPLv3+
161+
>>>>>>> Conflict 1 of 1 ends

qbpm/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# TODO real config file
2+
default_icon = "qutebrowser"
3+
application_name_suffix = " (qutebrowser profile)"

qbpm/favicon.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""
2+
SPDX-License-Identifier: MIT
3+
derived from favicon.py by Scott Werner
4+
https://github.com/scottwernervt/favicon/tree/123e431f53b2c4903b540246a85db0b1633d4786
5+
"""
6+
7+
import re
8+
from collections import defaultdict, namedtuple
9+
from html.parser import HTMLParser
10+
from pathlib import Path
11+
from typing import Any
12+
13+
import httpx
14+
15+
LINK_RELS = [
16+
"icon",
17+
"shortcut icon",
18+
]
19+
20+
SIZE_RE = re.compile(r"(?P<width>\d{2,4})x(?P<height>\d{2,4})", flags=re.IGNORECASE)
21+
22+
Icon = namedtuple("Icon", ["url", "width", "height", "format", "src"])
23+
24+
25+
def get(client: httpx.Client) -> list[Icon]:
26+
response = client.get("")
27+
response.raise_for_status()
28+
client.base_url = response.url
29+
30+
icons = {icon.url: icon for icon in tags(response.text)}
31+
32+
fallback_icon = fallback(client)
33+
if fallback_icon and fallback_icon.src not in icons:
34+
icons[fallback_icon.url] = fallback_icon
35+
36+
# print(f"{icons=}")
37+
return list(icons.values())
38+
# return sorted(icons, key=lambda i: i.width + i.height, reverse=True)
39+
40+
41+
def fallback(client: httpx.Client) -> Icon | None:
42+
response = client.head("favicon.ico")
43+
if response.status_code == 200 and response.headers["Content-Type"].startswith(
44+
"image"
45+
):
46+
return Icon(response.url, 0, 0, ".ico", "default")
47+
return None
48+
49+
50+
class LinkRelParser(HTMLParser):
51+
def __init__(self) -> None:
52+
super().__init__()
53+
self.icons: dict[str, set[str]] = defaultdict(set)
54+
55+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
56+
if tag == "link":
57+
data = dict(attrs)
58+
rel = data.get("rel")
59+
if rel in LINK_RELS and (href := data.get("href") or data.get("content")):
60+
# TODO replace with data
61+
self.icons[rel].add(href)
62+
63+
64+
def tags(html: str) -> set[Icon]:
65+
parser = LinkRelParser()
66+
parser.feed(html[0 : html.find("</head>")])
67+
hrefs = {link.strip() for links in parser.icons.values() for link in links}
68+
69+
icons = set()
70+
for href in hrefs:
71+
if not href or href.startswith("data:image/"):
72+
continue
73+
74+
# url_parsed = urlparse(url)
75+
# repair '//cdn.network.com/favicon.png' or `icon.png?v2`
76+
href_parsed = httpx.URL(href)
77+
78+
width, height = (0, 0) # dimensions(tag)
79+
ext = Path(href_parsed.path).suffix
80+
81+
icon = Icon(
82+
href_parsed,
83+
width,
84+
height,
85+
ext.lower(),
86+
"TODO",
87+
)
88+
icons.add(icon)
89+
90+
return icons
91+
92+
93+
def dimensions(tag: Any) -> tuple[int, int]:
94+
"""Get icon dimensions from size attribute or icon filename.
95+
96+
:param tag: Link or meta tag.
97+
:type tag: :class:`bs4.element.Tag`
98+
99+
:return: If found, width and height, else (0,0).
100+
:rtype: tuple(int, int)
101+
"""
102+
sizes = tag.get("sizes", "")
103+
if sizes and sizes != "any":
104+
size = sizes.split(" ") # '16x16 32x32 64x64'
105+
size.sort(reverse=True)
106+
width, height = re.split(r"[x\xd7]", size[0])
107+
else:
108+
filename = tag.get("href") or tag.get("content")
109+
size = SIZE_RE.search(filename)
110+
if size:
111+
width, height = size.group("width"), size.group("height")
112+
else:
113+
width, height = "0", "0"
114+
115+
# repair bad html attribute values: sizes='192x192+'
116+
width = "".join(c for c in width if c.isdigit())
117+
height = "".join(c for c in height if c.isdigit())
118+
return int(width), int(height)

qbpm/icons.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import re
2+
import shutil
3+
from collections.abc import Callable, Iterator
4+
from pathlib import Path
5+
from tempfile import TemporaryDirectory
6+
from typing import Optional
7+
8+
import httpx
9+
from PIL import Image
10+
11+
from . import Profile, __version__, favicon
12+
from .favicon import Icon
13+
from .utils import error, info
14+
15+
# TODO more preferences: 16x16, 32, 0, other
16+
PREFERRED_ICONS = [
17+
re.compile(p)
18+
for p in [
19+
r"favicon.*\.svg$",
20+
r"favicon.*\.ico$",
21+
r"favicon.*\.png$",
22+
r"\.ico$",
23+
r"icon\.png$",
24+
r"\.svg$",
25+
]
26+
]
27+
28+
29+
def choose_icon(icons: list[Icon]) -> Iterator[Icon]:
30+
preferred = PREFERRED_ICONS
31+
for pattern in preferred:
32+
for icon in icons:
33+
if pattern.search(icon.url.path):
34+
info(f"chose {icon.url}")
35+
yield icon
36+
icons.remove(icon)
37+
yield from choose_icon(icons)
38+
39+
40+
headers = {"user-agent": f"qbpm/{__version__}"}
41+
42+
43+
def download_icon(profile: Profile, home_page: str, overwrite: bool) -> Optional[Path]:
44+
base_url = httpx.URL(home_page)
45+
if not base_url.scheme:
46+
base_url = httpx.URL("https://" + home_page)
47+
client = httpx.Client(base_url=base_url, headers=headers, follow_redirects=True)
48+
tmp_dir = TemporaryDirectory()
49+
work_dir = Path(tmp_dir.name)
50+
try:
51+
for icon in choose_icon(favicon.get(client)):
52+
icon_body = client.get(icon.url)
53+
if icon_body.status_code != 200:
54+
info(f"got bad response code {icon_body.status_code} for {icon.url}")
55+
return None
56+
elif not icon_body.headers["Content-Type"].startswith("image"):
57+
info(f"{icon.url} is not an image")
58+
return None
59+
icon_body.raise_for_status()
60+
work_icon = work_dir / f"favicon{icon.format}"
61+
with work_icon.open("wb") as icon_file:
62+
for chunk in icon_body.iter_bytes(1024):
63+
icon_file.write(chunk)
64+
icon_path = install_icon_file(profile, work_icon, overwrite, icon.url)
65+
if icon_path:
66+
# print(f"installed {client.base_url.join(icon.url)}")
67+
return icon_path
68+
if not icon_path:
69+
# TODO pretty print
70+
info(f"no favicons found matching one of {PREFERRED_ICONS}")
71+
return None
72+
except Exception as e:
73+
# info(str(e))
74+
raise e
75+
error(f"failed to fetch favicon from {home_page}")
76+
return None
77+
finally:
78+
tmp_dir.cleanup()
79+
client.close()
80+
81+
return None
82+
83+
84+
def icon_for_profile(profile: Profile) -> Optional[str]:
85+
icon_file = next(find_icon_files(profile), None)
86+
if icon_file and icon_file.suffix == ".name":
87+
return icon_file.read_text()
88+
return str(icon_file) if icon_file else None
89+
90+
91+
def install_icon_file(
92+
profile: Profile, src: Path, overwrite: bool, origin: Optional[str] = None
93+
) -> Optional[Path]:
94+
clean_icons = check_for_icons(profile, overwrite)
95+
if clean_icons is None:
96+
return None
97+
icon_format = src.suffix
98+
dest = profile.root / f"icon{icon_format}"
99+
if icon_format not in {".png", ".svg"}:
100+
dest = dest.with_suffix(".png")
101+
try:
102+
image = Image.open(src)
103+
clean_icons(set())
104+
image.save(dest, format="png")
105+
except Exception as e:
106+
error(str(e))
107+
error(f"failed to convert {origin or src} to png")
108+
dest.unlink(missing_ok=True)
109+
return None
110+
else:
111+
if src.resolve() != dest:
112+
shutil.copy(src, dest)
113+
clean_icons({dest})
114+
print(dest)
115+
return dest.absolute()
116+
117+
118+
def install_icon_by_name(profile: Profile, icon_name: str, overwrite: bool) -> bool:
119+
clean_icons = check_for_icons(profile, overwrite)
120+
if clean_icons is None:
121+
return False
122+
clean_icons(set())
123+
file = profile.root / "icon.name"
124+
file.write_text(icon_name)
125+
return True
126+
127+
128+
def check_for_icons(
129+
profile: Profile, overwrite: bool
130+
) -> Callable[[set[Path]], None] | None:
131+
existing_icons = set(find_icon_files(profile))
132+
if existing_icons and not overwrite:
133+
error(f"icon already exists in {profile.root}, pass --overwrite to replace it")
134+
return None
135+
136+
def clean_icons(keep: set[Path]) -> None:
137+
for icon in existing_icons - keep:
138+
icon.unlink()
139+
140+
return clean_icons
141+
142+
143+
def find_icon_files(profile: Profile) -> Iterator[Path]:
144+
for ext in ["png", "svg", "name"]:
145+
icon = profile.root / f"icon.{ext}"
146+
if icon.is_file():
147+
yield icon.absolute()

0 commit comments

Comments
 (0)