Skip to content

Commit f4436c5

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

File tree

12 files changed

+447
-34
lines changed

12 files changed

+447
-34
lines changed

contrib/PKGBUILD

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,18 @@ pkgver=1.0.rc2.r1
55
pkgrel=1
66
pkgdesc="A profile manager for qutebrowser"
77
url="https://github.com/pvsr/qbpm"
8-
license=('GPL')
8+
license=('GPL-3.0-or-later')
99
sha512sums=('SKIP')
1010
arch=('any')
1111
depends=('python' 'python-pyxdg' 'python-click')
12-
makedepends=('git' 'python-build' 'python-installer' 'python-wheel' 'python-setuptools' 'scdoc')
12+
makedepends=('git'
13+
'python-build'
14+
'python-installer'
15+
'python-wheel'
16+
'python-setuptools'
17+
'python-httpx'
18+
'python-pillow'
19+
'scdoc')
1320
provides=('qbpm')
1421
source=("git+https://github.com/pvsr/qbpm")
1522

default.nix

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,18 @@
44
pythonPackages ? builtins.getAttr (python + "Packages") pkgs,
55
}:
66
with pythonPackages;
7-
buildPythonPackage rec {
7+
buildPythonApplication rec {
88
pname = "qbpm";
99
version = "1.0-rc2";
10+
pyproject = true;
1011
src = ./.;
1112
doCheck = true;
12-
format = "pyproject";
13-
nativeBuildInputs = [pkgs.scdoc setuptools];
14-
propagatedBuildInputs = [pyxdg click];
15-
checkInputs = [pytest];
13+
14+
build-system = [setuptools];
15+
nativeBuildInputs = [pkgs.scdoc];
16+
dependencies = [pyxdg click httpx pillow];
17+
nativeCheckInputs = [pytestCheckHook];
18+
1619
postInstall = ''
1720
install -D -m644 completions/qbpm.fish $out/share/fish/vendor_completions.d/qbpm.fish
1821
@@ -23,4 +26,11 @@ with pythonPackages;
2326
mkdir -p $out/share/man/man1
2427
scdoc < qbpm.1.scd > $out/share/man/man1/qbpm.1
2528
'';
29+
30+
meta = {
31+
homepage = "https://github.com/pvsr/qbpm";
32+
changelog = "https://github.com/pvsr/qbpm/blob/main/CHANGELOG.md";
33+
description = "A profile manager for qutebrowser";
34+
license = lib.licenses.gpl3Plus;
35+
};
2636
}

flake.nix

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,15 @@
3939
packages = with pkgs;
4040
[
4141
ruff
42+
scdoc
43+
xdg-utils
4244
(python3.withPackages (ps:
4345
with ps; [
4446
pytest
4547
mypy
4648
pylsp-mypy
4749
ruff-lsp
50+
types-pillow
4851
]))
4952
]
5053
++ self.packages.x86_64-linux.default.propagatedBuildInputs;

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ classifiers = [
1818
"Typing :: Typed",
1919
]
2020
requires-python = ">= 3.9"
21-
dependencies = ["pyxdg", "click"]
21+
dependencies = ["pyxdg", "click", "httpx", "pillow"]
2222

2323
[project.urls]
24+
homepage = "https://github.com/pvsr/qbpm"
2425
repository = "https://github.com/pvsr/qbpm"
26+
changelog = "https://github.com/pvsr/qbpm/blob/main/CHANGELOG.md"
2527

2628
[project.scripts]
2729
qbpm = "qbpm.main:main"
@@ -42,7 +44,7 @@ warn_return_any = true
4244
warn_unused_ignores = true
4345

4446
[[tool.mypy.overrides]]
45-
module = "xdg.*"
47+
module = ["xdg.*"]
4648
ignore_missing_imports = true
4749

4850
[[tool.mypy.overrides]]

qbpm.1.scd

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,18 @@ $HOME/.local/share), but this can be set to another directory by passing
8181
qbpm launch -n qb-dev --debug --json-logging
8282
```
8383

84+
*icon* [options] <profile> <icon>
85+
Install an icon to _profile_. _icon_ may be a url, a path to an image file,
86+
or, if --by-name is passed, the name of an xdg icon installed on your
87+
system. If _icon_ is a url, qbpm will fetch the page and attempt to find a
88+
suitable favicon.
89+
90+
Options:
91+
92+
*-n, --by-name*
93+
Interpret _icon_ as the name of an xdg icon file according to the
94+
freedesktop.org icon specification. Likely to only work on Linux.
95+
8496
*choose* [options]
8597
Open a menu to choose a qutebrowser profile to launch. On linux this defaults
8698
to dmenu or another compatible menu program such as rofi, and on macOS this
@@ -120,4 +132,14 @@ $HOME/.local/share), but this can be set to another directory by passing
120132

121133
Peter Rice
122134

123-
Contribute at https://github.com/pvsr/qbpm
135+
# CONTRIBUTE
136+
137+
_https://github.com/pvsr/qbpm_
138+
139+
_https://codeberg.org/pvsr/qbpm_
140+
141+
# LICENSE
142+
143+
src/qbpm/favicon.py: MIT
144+
145+
all other code and qbpm as a whole: GPLv3+

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)

0 commit comments

Comments
 (0)