diff --git a/.gitignore b/.gitignore index 5975d974f..9feb88a69 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *~ build/ +.venv/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 174140b85..15114e1f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,5 +28,22 @@ Since this set is designed specifically for elementary OS, pull requests to add Use of icon names in line with the [FreeDesktop.Org Icon Naming Specification](http://standards.freedesktop.org/icon-naming-spec/icon-naming-spec-latest.html) is encouraged. +To monitor coverage, an auditing python script is included under `/scripts`. While this +script can be used without any extra dependencies, setting up a virtual +environment and installing `requests` and `BeautifulSoup4` enables functionality +that ensures all resources pulled are as current as possible. To do this: + +1. If not already installed, run `apt install python3 python3.12-venv` +2. From the repository root, run `python3 -m venv .venv`. This sets up a python + virtual environment in a hidden folder, scoped to this repository. +3. To activate the venv, run `source .venv/bin/activate`. +4. To install `requests` and `BeautifulSoup4`, run `python3 -m pip install requests + BeautifulSoup4` +5. Run the script with `python3 scripts/audit.py ` + +Note that for accurate results, the fully built theme directory has to be +targeted, not the development files. Many symlinks are created by the build +system, so will not be picked up if this script is run on the development files. + ## Third-Party Brand Preservation elementary icons do not attempt to supply icons for third-party apps. Pull requests to add icons or symbolic links that would overwrite the branding of third-party apps will be rejected. diff --git a/scripts/audit.py b/scripts/audit.py new file mode 100755 index 000000000..9394f9f0f --- /dev/null +++ b/scripts/audit.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 + +# # Fd.o Icon Name Specification Audit + +# ## Description: +# +# Audit a Freedesktop.org icon specification-compatible theme against the icon +# naming specification, returning a report of what icons were included that are +# in-spec, what icons were included that are out-of-spec, and what icons were +# missing that are included in the spec. + +# ## Dependencies: +# +# - requests (optional) +# - bs4 (optional) + +import os, csv, argparse + +parser = argparse.ArgumentParser( + prog = "audit", + description = "Audit a FreeDesktop.org compatible icon set against a specification" +) +parser.add_argument( + "theme_path", +) +parser.add_argument( + "-s", + "--specification", + dest="specf" +) +parser.add_argument( + "-v", + "--verbose", + action="store_true" +) +parser.add_argument( + "-o", + "--output", + dest="fout", +) +parser.add_argument( + "-d", + "--display-results", + dest="to_display", + choices=["all", "found", "missing", "extra"], + default="all" +) +args = parser.parse_args() + +# make hrules pretty +try: + view_width = os.get_terminal_size().columns +except: + view_width = 80 + +# Check for optional dependencies to enable drawing the spec from the web. +# Otherwise fall back to included file +try: + found_deps = False + import requests + from bs4 import BeautifulSoup +except: + print("Couldn't find bs4 and requests dependencies, falling back to using spec file…") +else: + if args.specf is None: + print("Found bs4 and requests dependencies, pulling spec from the web…") + found_deps = True + +def get_soup(url: str) -> BeautifulSoup: + page = session.get(url) + soup = BeautifulSoup(page.text, "html.parser") + return soup + +# This is kinda not great but it works +def parse_soup(soup: BeautifulSoup) -> list: + result = [] + + tables = soup.find_all("table") + #remove the context description table, we don't care about it here + tables.pop(0) + for table in tables: + rows = table.find_all("tr") + for row in rows: + text = row.contents + name = text[0].text.strip().replace("\n", "") + # skip the header row + if name == "Name": continue + result.append(name) + + return result + +def pad_list(iterable: list, length: int) -> list: + extension_length = max(length - len(iterable), 0) + if args.verbose: print(f"Extending list by {extension_length} elements") + for _ in range(extension_length): + iterable.append(None) + + return iterable + +# Place this in a top level context so we don't have to pass it around +ignore_list = [] + +def populate_from_dir(dir: os.DirEntry) -> set: + # This function is called recursively to populate a list + tree = [] + + for entry in os.scandir(dir.path): + if entry.name in ignore_list: + continue + + if entry.is_dir(): + if args.verbose: print(f"Found dir at {entry.path}, going deeper") + # A spoonful of recursion to help the medicine go down + tree.extend(populate_from_dir(entry)) + elif entry.name.endswith(".svg"): + # We only check svg because that's the only type we use in elementary icons + if args.verbose: print(f"Adding file {entry.path}, named {entry.name} to SubTree") + tree.append(entry.name.removesuffix(".svg")) + + if args.verbose: print(f"The SubTree for Dir {dir.name} is {tree}") + return list(set(tree)) + +# Load a text stream of the spec into a dictionary, where KEY=icon_name & +# VALUE=a bool denoting whether the file was found in the file tree +# Iterate over the list, checking to see if the specified file can be found. +specification = {} + +if found_deps: + url = "https://specifications.freedesktop.org/icon-naming-spec/latest/" + headers = { + 'User-Agent': 'fdo-icon-name-spec-audit-script/1.0.0', + } + + session = requests.Session() + session.headers.update(headers) + + soup = get_soup(url) + spec_list = parse_soup(soup) + specification = specification.fromkeys(spec_list, False) +else: + with open(args.specf, "r") as file: + print(f"Loading specification in file: {args.specf}…") + for line in file: + if line.startswith(("//", " ", "\n")): + continue + specification.setdefault(line.removesuffix("\n"), False) + +print("Successfully loaded specification!") + +# Change the working dir to our target dir, to make it easier to traverse the +# tree. Try to find the file `.auditignore` in the dir root and load it into +# a list. Anything in this list doesn't exist as far as this script is +# concerned. +try: + target_dir = args.theme_path +except: + print("Please specify a directory") + exit() +else: + # Save the cwd so we can switch back later + script_dir = os.getcwd() + dir = os.fspath(target_dir) + os.chdir(dir) + +try: + with open(".auditignore", "r") as file: + print("Found .auditignore file!") + for line in file: + ignore_list.append(line.strip()) +except: + print("No .auditignore file found in root") +else: + print("Loaded .auditignore file!") + if args.verbose: print(f"Values contained: {ignore_list}") + +print('-' * view_width) + +# Generate the contents of the relevant directory tree +contents = [] + +for entry in os.scandir(): + if entry.name not in ignore_list: + if entry.is_dir(): + if args.verbose: print(f"Found dir at {entry.path}") + sub_dir_tree = populate_from_dir(entry) + contents.extend(sub_dir_tree) + else: + if entry.name.endswith(".svg") and not + entry.name.endswith("-symbolic.svg"): + if args.verbose: print(f"Adding file {entry.path} to tree") + contents.append(entry.name.removesuffix(".svg")) + +# remove duplicate names from the list +contents = list(set(contents)) + +if args.verbose: + print(f"The full dirTree is {contents}") + print('-' * view_width) + +# Traverse the dir tree, checking whether the found file is in the spec list. +# This is so we don't have to traverse the entire dir tree of the set, just the +# specification list which is likely to be much smaller. + +for entry in specification.keys(): + if entry in contents: + print(f"Found {entry}") + specification |= {entry: True} + else: + print(f"\033[31m[!!] {entry} is missing!\033[0m") + + +# Calculate percent spec coverage +total_entries = len(specification.keys()) +existant_color_entries = list(specification.values()).count(True) +color_results = list(specification.values()) + +print(f"{existant_color_entries / total_entries * 100:.2f}% coverage of specification") + +print('-' * view_width) + +if args.fout is not None: + os.chdir(script_dir) + + # Write report file with Found, Missing, and Extra information + spec_list = list(specification.keys()) + found_entries = [] + missing_entries = [] + extra_entries = [] + + # We only look at the color entry results, as symbolic is not officially in the spec + for i, result in enumerate(color_results): + # If the result is true, then it was found + # If the result is false, then it is missing + name = spec_list[i] + if result: + if args.verbose: print(f"Adding {name} to Found…") + found_entries.append(name) + else: + if args.verbose: print(f"Adding {name} to Missing…") + missing_entries.append(name) + + # Now we want to go through all of the entries we found in the initial contents and + # point out those that aren't in the spec + for entry in contents: + if entry not in spec_list and not entry.endswith("-symbolic"): + if args.verbose: print(f"Adding {entry} to Extra") + extra_entries.append(entry) + + # Pad all lists to be the same length, then zip all three lists together + length = max(len(found_entries), len(missing_entries), len(extra_entries)) + + with open(args.fout, 'w', newline='') as file: + fwriter = csv.writer(file) + match args.to_display: + case "found": + fwriter.writerow(["Found"]) + fwriter.writerows(map(lambda x: [x], found_entries)) + case "missing": + fwriter.writerow(["Missing"]) + fwriter.writerows(map(lambda x: [x], missing_entries)) + case "extra": + fwriter.writerow(["Extra"]) + fwriter.writerows(map(lambda x: [x], extra_entries)) + case _: + fwriter.writerow(["Found", "Missing", "Extra"]) + + found_entries = pad_list(found_entries, length) + missing_entries = pad_list(missing_entries, length) + extra_entries = pad_list(extra_entries, length) + zipped_lists = [(a, b, c) for a, b, c in zip(found_entries, + missing_entries, extra_entries)] + fwriter.writerows(zipped_lists) + + print(f"Report written to {os.getcwd()}/{args.fout}") + diff --git a/scripts/fd.o-icon-spec-v0.8.90.txt b/scripts/fd.o-icon-spec-v0.8.90.txt new file mode 100644 index 000000000..a5e1bd0b5 --- /dev/null +++ b/scripts/fd.o-icon-spec-v0.8.90.txt @@ -0,0 +1,291 @@ +// Freedesktop.org Icon Naming Specification +// Version 0.8.90 +// Date: 2025-02-05 +// https://specifications.freedesktop.org/icon-naming-spec/latest/ + +address-book-new +application-exit +appointment-new +call-start +call-stop +contact-new +document-new +document-open +document-open-recent +document-page-setup +document-print +document-print-preview +document-properties +document-revert +document-save +document-save-as +document-send +edit-clear +edit-copy +edit-cut +edit-delete +edit-find +edit-find-replace +edit-paste +edit-redo +edit-select-all +edit-undo +folder-new +format-indent-less +format-indent-more +format-justify-center +format-justify-fill +format-justify-left +format-justify-right +format-text-direction-ltr +format-text-direction-rtl +format-text-bold +format-text-italic +format-text-underline +format-text-strikethrough +go-bottom +go-down +go-first +go-home +go-jump +go-last +go-next +go-previous +go-top +go-up +help-about +help-contents +help-faq +insert-image +insert-link +insert-object +insert-text +list-add +list-remove +mail-forward +mail-mark-important +mail-mark-junk +mail-mark-notjunk +mail-mark-read +mail-mark-unread +mail-message-new +mail-reply-all +mail-reply-sender +mail-send +mail-send-receive +media-eject +media-playback-pause +media-playback-start +media-playback-stop +media-record +media-seek-backward +media-seek-forward +media-skip-backward +media-skip-forward +object-flip-horizontal +object-flip-vertical +object-rotate-left +object-rotate-right +process-stop +system-lock-screen +system-log-out +system-run +system-search +system-reboot +system-shutdown +tools-check-spelling +view-fullscreen +view-refresh +view-restore +view-sort-ascending +view-sort-descending +window-close +window-new +zoom-fit-best +zoom-in +zoom-original +zoom-out +process-working +accessories-calculator +accessories-character-map +accessories-dictionary +accessories-screenshot-tool +accessories-text-editor +help-browser +multimedia-volume-control +preferences-desktop-accessibility +preferences-desktop-font +preferences-desktop-keyboard +preferences-desktop-locale +preferences-desktop-multimedia +preferences-desktop-screensaver +preferences-desktop-theme +preferences-desktop-wallpaper +system-file-manager +system-software-install +system-software-update +utilities-system-monitor +utilities-terminal +applications-accessories +applications-development +applications-engineering +applications-games +applications-graphics +applications-internet +applications-multimedia +applications-office +applications-other +applications-science +applications-system +applications-utilities +preferences-desktop +preferences-desktop-peripherals +preferences-desktop-personal +preferences-other +preferences-system +preferences-system-network +system-help +audio-card +audio-input-microphone +battery +camera-photo +camera-video +camera-web +computer +drive-harddisk +drive-optical +drive-removable-media +input-gaming +input-keyboard +input-mouse +input-tablet +media-flash +media-floppy +media-optical +media-tape +modem +multimedia-player +network-wired +network-wireless +pda +phone +printer +scanner +video-display +emblem-default +emblem-documents +emblem-downloads +emblem-favorite +emblem-important +emblem-mail +emblem-photos +emblem-readonly +emblem-shared +emblem-symbolic-link +emblem-synchronized +emblem-system +emblem-unreadable +face-angel +face-angry +face-cool +face-crying +face-devilish +face-embarrassed +face-kiss +face-laugh +face-monkey +face-plain +face-raspberry +face-sad +face-sick +face-smile +face-smile-big +face-smirk +face-surprise +face-tired +face-uncertain +face-wink +face-worried +application-x-executable +audio-x-generic +font-x-generic +image-x-generic +package-x-generic +text-html +text-x-generic +text-x-generic-template +text-x-script +video-x-generic +x-office-address-book +x-office-calendar +x-office-document +x-office-presentation +x-office-spreadsheet +folder +folder-remote +network-server +network-workgroup +start-here +user-bookmarks +user-desktop +user-home +user-trash +appointment-missed +appointment-soon +audio-volume-high +audio-volume-low +audio-volume-medium +audio-volume-muted +battery-caution +battery-low +dialog-error +dialog-information +dialog-password +dialog-question +dialog-warning +folder-drag-accept +folder-open +folder-visiting +image-loading +image-missing +mail-attachment +mail-unread +mail-read +mail-replied +mail-signed +mail-signed-verified +media-playlist-repeat +media-playlist-shuffle +network-error +network-idle +network-offline +network-receive +network-transmit +network-transmit-receive +printer-error +printer-printing +security-high +security-medium +security-low +software-update-available +software-update-urgent +sync-error +sync-synchronizing +task-due +task-past-due +user-available +user-away +user-idle +user-offline +user-trash-full +weather-clear +weather-clear-night +weather-few-clouds +weather-few-clouds-night +weather-fog +weather-overcast +weather-severe-alert +weather-showers +weather-showers-scattered +weather-snow +weather-storm