diff --git a/.github/workflows/profile-3d.yml b/.github/workflows/profile-3d.yml deleted file mode 100644 index ee7b5be..0000000 --- a/.github/workflows/profile-3d.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: GitHub-Profile-3D-Contrib - -on: - # schedule: # 03:00 JST == 18:00 UTC - # - cron: "0 1 * * *" - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - name: generate-github-profile-3d-contrib - steps: - - uses: actions/checkout@v3 - - uses: yoshi389111/github-profile-3d-contrib@0.7.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - USERNAME: ${{ github.repository_owner }} - - name: Commit & Push - run: | - git config user.name github-actions - git config user.email github-actions@github.com - git add -A . - git commit -m "generated" - git push diff --git a/.github/workflows/update-github-activity.yml b/.github/workflows/update-github-activity.yml deleted file mode 100644 index e510432..0000000 --- a/.github/workflows/update-github-activity.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: update github activity - -on: - #schedule: - # - cron: '*/30 * * * *' - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - name: Update this repo's README with recent activity - - steps: - - uses: actions/checkout@v4 - - uses: jamesgeorge007/github-activity-readme@master - env: - GITHUB_TOKEN: ${{ secrets.SODERLIND_TOKEN }} - with: - MAX_LINES: 5 diff --git a/.github/workflows/update-plugins.yml b/.github/workflows/update-plugins.yml index c7be77c..2ef883a 100644 --- a/.github/workflows/update-plugins.yml +++ b/.github/workflows/update-plugins.yml @@ -26,8 +26,8 @@ jobs: - name: Update README env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: python update_plugins_readme.py + SODERLIND_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python build_readme.py - name: Commit & Push run: | diff --git a/README.md b/README.md index d7a5d6d..f41c621 100644 --- a/README.md +++ b/README.md @@ -1,105 +1,370 @@ +
-kredittkort +
+# Per Søderlind — WordPress Developer & Plugin Author + +I'm a Senior Advisor and open source **WordPress developer** from Norway, focused on **plugin development**, **AI integrations for WordPress**, **WordPress Multisite** solutions, and **developer tooling** for GitHub Copilot and VS Code. + +I build and maintain popular open source WordPress plugins including [WP Loupe](https://github.com/soderlind/wp-loupe) (fast, typo-tolerant search), [Virtual Media Folders](https://github.com/soderlind/virtual-media-folders) (media library organization), and [Super Admin All Sites Menu](https://github.com/soderlind/super-admin-all-sites-menu) (multisite management), alongside a growing collection of AI-powered plugins, VS Code extensions, and AI skills. + +- 🌐 Website: [soderlind.no](https://soderlind.no) +- 🧩 Plugins on [WordPress.org](https://profiles.wordpress.org/pers/) and [GitHub](https://github.com/soderlind?tab=repositories) +- 🛠️ Focus: WordPress, PHP, JavaScript, AI integration, GitHub Copilot, GitHub Actions, IaC, DevSecOps + ## WordPress Plugins -Repos below are my WordPress plugins hosted here at GitHub. Easy to install, and the plugin [updates are handled automatically](https://github.com/soderlind/wordpress-plugin-github-updater?tab=readme-ov-file#wordpress-plugin-github-updater) via GitHub. No need to manually download and install updates. +Repos below are my WordPress plugins hosted here at GitHub. Easy to install. [Plugin updates are handled automatically](https://github.com/soderlind/wordpress-plugin-github-updater?tab=readme-ov-file#wordpress-plugin-github-updater) via GitHub. No need to manually download and install updates. +Virtual Media Folders - WordPress plugin for media library organization + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - +
-
-
-
Virtual Media Folders 🚀 ⭐ 1
-
Virtual Media Folders brings virtual folder organization to your WordPress Media Library. Organize your media files into hierarchical folders without moving files on disk—folders are virtual, so your URLs never change.
-
-
+
+ +### Virtual Media Folders + +

Virtual Media Folders brings folder organization to your WordPress Media Library. Organize your media files into hierarchical folders without moving files on disk. Folders are virtual, so your URLs never change. Also available on WordPress.org.

Add-ons available:

+ +
+ +**Add-On Manager** ⭐ 4 + +🚀 Install and manage add-ons that extend Virtual Media Folders. + + + +**AI Ability** ⭐ 1 + +Exposes Virtual Media Folders operations as WordPress Abilities API tools for AI agents and MCP adapters. + +
+ +**AI Organizer** ⭐ 5 + +Uses vision-capable AI models to analyze actual image content and automatically organize your media library into virtual folders. This is add-on functionality requiring an API key from a supported AI service provider, or a local LLM. + + + +**Editorial Workflow** ⭐ 1 + +Role-based folder access, move restrictions, and Inbox workflow for Virtual Media Folders. + +
+ +**Folder Exporter** ⭐ 1 + +Export folders (or subtrees) as ZIP archives with optional CSV manifests. + + + +**Media Cleanup** ⭐ 2 + +Tools to identify and clean up unused or duplicate media files. + +
+ +**Migrate** ⭐ 3 + +Import folders and file assignments from other media folder plugins. Supports FileBird, Enhanced Media Library, Real Media Library,HappyFiles, WP Media Folder, CatFolders and Media Library Assistant. + + + +**Rules Engine** ⭐ 1 + +Rule-based automatic folder assignment for media uploads, based on metadata, file type, or other criteria. + +
+ +### :robot: AI Experiments :robot: + +Some are in early beta, while others are production ready, and all explore how AI can be used in WordPress. + +
+ +**Admin Coach Tours** ⭐ 3 + +AI-powered interactive tutorials for the WordPress block editor. + + + +**AI Alt Text** ⭐ 1 + +Generate alt text for images using AI. Supports multiple AI providers including OpenAI, Claude, Gemini, Ollama, Azure OpenAI, and Grok. + +
+ +**AI Provider for Azure AI Foundry** ⭐ 3 + +

Connect WordPress 7.0+ to Azure AI Foundry for text generation, image generation, embeddings, and more.

Also available on WordPress.org.

+ +
+ +**AI Provider for exo** + +

Connect WordPress to exo, run frontier AI models locally on your device cluster.

Also available on WordPress.org.

+ +
+ +**AI Router** ⭐ 6 + +Route AI requests to different provider configurations based on capability. Configure multiple instances of the same AI provider (e.g., GPT-4o for text, gpt-image-1 for images) and let AI Router automatically select the right one. + + + +**AI Valve** ⭐ 19 + +Control, meter, and permission-gate AI usage from plugins that connect through the WordPress 7 AI connector. + +
+ +**Content Poll** ⭐ 2 + +A modern, accessible polling block that lets visitors vote on questions about your content. Generate the poll using AI. Supports multiple AI providers including OpenAI, Claude, Gemini, Ollama, Azure OpenAI, and Grok. + + + +**LLMS MD** + +Provides a generated site.tld/llms.md endpoint for WordPress using cached AI-driven site analysis. + +
+ +**PS Design System** + +Extract design systems from WordPress themes using deterministic parsing and AI synthesis. + + + +**Talking Head** ⭐ 3 + +Talking Head lets you write multi-speaker conversations in the WordPress block editor, then generate podcast-quality audio using AI text-to-speech. Each speaker ("head") gets their own voice, and the plugin stitches the segments together into a single audio file with configurable silence gaps. + +
+ +### WordPress Multisite Plugins + +
+ +**All Sites Cron** ⭐ 7 + +Run wp-cron on all public sites in a multisite network + + + +**Multisite Exporter** ⭐ 6 + +Multisite Exporter is a WordPress plugin that allows you to export content from all subsites in a WordPress multisite installation. The plugin generates WordPress XML (WXR) files by running the WordPress exporter on each subsite in the background using the Action Scheduler library, making it efficient even for large multisite networks. + +
+ +**Network Style Override** + +Network-wide CSS and theme.json overrides for WordPress Multisite. Enforce brand consistency across all subsites from a single admin panel. + + + +**Super Admin All Sites Menu** ⭐ 34 + +

For the super admin, replace WP Admin Bar My Sites menu with an All Sites menu.

Also available on WordPress.org.

+ +
+ +**Super Admin Switch To Admin** ⭐ 7 + +If you are logged in as a super admin, this User Switching companion allows you to switch to a regular admin account on the current site. + +
+ +### Miscellaneous WordPress Plugins + +
+ +**Additional Javascript** ⭐ 4 + +Use WordPress Customizer to add JavaScript + + + +**Cache Tags for Cloudflare** ⭐ 1 + +

Precise Cloudflare cache purging for WordPress: adds Cache-Tag headers and purges only affected posts, pages, and terms.

Also available on WordPress.org.

+ +
+ +**Color Me Beautiful** ⭐ 1 + +Personalise the WordPress admin with your own accent colour. + + + +**Custom Document Folder** ⭐ 3 + +Organize document uploads by automatically directing specific file types to custom folders based on their extensions. +
-
-
Additional Javascript ⭐ 4
-
Use WordPress Customizer to add JavaScript
-
+ +**Editor Can Manage Privacy Options** ⭐ 1 + +A lightweight WordPress plugin that grants the Editor role access to manage site Privacy Settings — capabilities normally restricted to Administrators. +
-
-
Ai Alt Text ⭐ 1
-
Generate alt text for images using AI. Supports multiple AI providers including OpenAI, Claude, Gemini, Ollama, Azure OpenAI, and Grok.
-
+ +**Gravity Forms HTML Area Editor** ⭐ 2 + +Adds a rich text editor (TinyMCE) to the Gravity Forms HTML field content setting, replacing the default plain textarea. +
-
-
All Sites Cron ⭐ 7
-
Run wp-cron on all public sites in a multisite network
-
+ +**PassWP - Posts** ⭐ 1 + +A simple password protection plugin for WordPress—no usernames, no accounts, just one shared password. Share the password with those who need access and they're in. Perfect for situations where you need quick, hassle-free access control without user management. +
-
-
Content Poll ⭐ 2
-
A modern, accessible polling block that lets visitors vote on questions about your content. Generate the poll using AI. Supports multiple AI providers including OpenAI, Claude, Gemini, Ollama, Azure OpenAI, and Grok.
-
+ +**PS Hyphenate** ⭐ 2 + +WordPress plugin that improves text wrapping for long compound words in languages like German, Norwegian, Swedish, and Dutch. +
-
-
Custom Document Folder ⭐ 3
-
Organize document uploads by automatically directing specific file types to custom folders based on their extensions.
-
+ +**Push Notifications for Trigv** ⭐ 3 + +Turns WordPress events into Trigv push notifications: posts, comments, failed logins, updates, new users, or your own custom events. +
-
-
Editor Can Manage Privacy Options ⭐ 1
-
A lightweight WordPress plugin that grants the Editor role access to manage site Privacy Settings — capabilities normally restricted to Administrators.
-
+ +**Read Offline** ⭐ 33 + +Read Offline allows you to download posts and pages. You can download the post as PDF, ePub and markdown +
-
-
Multisite Exporter ⭐ 5
-
Multisite Exporter is a WordPress plugin that allows you to export content from all subsites in a WordPress multisite installation. The plugin generates WordPress XML (WXR) files by running the WordPress exporter on each subsite in the background using the Action Scheduler library, making it efficient even for large multisite networks.
-
+ +**Redis Queue** ⭐ 3 + +Robust Redis-backed background job processing for WordPress. Provides prioritized, delayed, and retryable jobs with an admin UI, REST API, token-based auth (scopes + rate limiting), and extensibility for custom job types. +
-
-
Read Offline ⭐ 33
-
Read Offline allows you to download posts and pages. You can download the post as PDF, ePub and markdown
-
+ +**TEC Sort by End Date** + +Sort The Events Calendar events by end date instead of start date. +
-
-
Redis Queue ⭐ 1
-
Robust Redis-backed background job processing for WordPress. Provides prioritized, delayed, and retryable jobs with an admin UI, REST API, token-based auth (scopes + rate limiting), and extensibility for custom job types.
-
+ +**WP Loupe** ⭐ 88 + +WP Loupe is a powerful search enhancement plugin for WordPress that delivers fast, accurate, and typo-tolerant search results. WP Loupe exposes a developer-friendly API so you can build your own search UI. WP Loupe works out of the box with WordPress’s standard search. +
-
-
Super Admin Switch To Admin ⭐ 7
-
If you are logged in as a super admin, this plugin allows you to switch to a regular admin account on the current site.
-
+ +**WP Loupe - Admin Search Add-On** ⭐ 4 + +A faster, smarter search experience for the WordPress admin. This add-on replaces the default admin search with WP Loupe-powered typo-tolerant, relevance-ranked results. +
-
-
Wp Loupe ⭐ 79
-
WP Loupe is a powerful search enhancement plugin for WordPress that delivers fast, accurate, and typo-tolerant search results.
-
+ +**WP Portable Text** ⭐ 8 + +A WordPress plugin that replaces the Gutenberg block editor with a Portable Text editor. Content is stored as structured JSON in post_content and rendered to HTML via PHP on the front end. +
+ ## WordPress & GitHub Copilot @@ -107,5 +372,52 @@ Repos below are my WordPress plugins hosted here at GitHub. Easy to install, and I made the [WordPress Development — Copilot Instructions](https://github.com/github/awesome-copilot/blob/main/instructions/wordpress.instructions.md) (please improve them) at [Awesome GitHub Copilot Customizations](https://github.com/github/awesome-copilot). You can install them by clicking:
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fwordpress.instructions.md) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode-insiders%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fwordpress.instructions.md) + **Spec-Driven WordPress Development**, clearly stated. Everything flows from the [**Constitution**](https://github.com/soderlind/wordpress-sdd). +## Skills + +Install the skills with the following command, which lets you pick all of them or select individual ones: + +```bash +npx skills add soderlind/skills +``` + +You can also install a specific skill directly with `npx skills add soderlind/skills --skill `. + +| Skill | Purpose | +| --- | --- | +| [`wp-cli-local`](https://skills.sh/soderlind/skills/wp-cli-local) | Run WP-CLI commands against Local by Flywheel sites on macOS. | +| [`wp-pcp-local`](https://skills.sh/soderlind/skills/wp-pcp-local) | Run the WordPress Plugin Check (PCP) against Local by Flywheel sites on macOS. | +| [`prepare-wordpress`](https://skills.sh/soderlind/skills/prepare-wordpress) | Scaffold or update a WordPress project with dev tooling, coding standards, testing, and i18n support. | +| [`wp-bump`](https://skills.sh/soderlind/skills/wp-bump) | Bump a WordPress plugin version and update related release metadata. | +| [`browser-native`](https://skills.sh/soderlind/skills/browser-native) | Audit JavaScript dependencies and identify packages replaceable by modern browser/runtime native APIs. | +| [`add-apim-api`](https://skills.sh/soderlind/skills/add-apim-api) | Scaffold a new API in Azure API Management with Bicep infrastructure. | + +## VS Code Extensions + +**[LocalWP Assistant — VS Code Chat Participant](https://github.com/soderlind/vs-local-wp)** +A GitHub Copilot Chat participant (@localwp) that lets you query and manage your Local by Flywheel WordPress sites directly from VS Code. Also available on the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=persoderlind.localwp-assistant). + +**[WordPress Readme](https://github.com/soderlind/wordpress-readme-preview)** +A Visual Studio Code extension that provides syntax highlighting, IntelliSense, live preview and validation for WordPress plugin readme.txt files with pixel-perfect WordPress.org rendering and comprehensive compliance checking. Also available on the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=persoderlind.wordpress-readme-preview). + +## Ralph + +**[Ralph](https://github.com/soderlind/ralph)** +A thin runner around GitHub Copilot CLI that runs it in a loop, implementing one feature at a time until your PRD is complete—let AI ship working code while you sleep. + +**[Ralph for WordPress projects](https://github.com/soderlind/ralph-wp)** +A tiny runner around GitHub Copilot CLI that loops in small, clean iterations—1 feature per run + commit, progress log, and a PRD checklist. + +**[Ralph WP Testing](https://github.com/soderlind/ralph-wp-testing)** +Shows how to build an end-to-end WordPress plugin testing setup with complete PHPUnit coverage, built for automated runs with Ralph. + +## WP FotoKopilot + +**[WP FotoKopilot](https://github.com/soderlind/wp-fotokopilot)** +WP FotoKopilot is a cross-platform Electron desktop app that connects to WordPress sites via the REST API, scans the media library, and generates missing (or improved) alt text using the GitHub Copilot SDK. Optionally organizes media into [Virtual Media Folders (VMF)](https://github.com/soderlind/virtual-media-folders). + +_I did this as a PoC, testing [GitHub Copilot SDK](https://github.com/github/copilot-sdk?tab=readme-ov-file#github-copilot-cli-sdks). Developer guide is available: [WP FotoKopilot Developer Documentation](https://github.com/soderlind/wp-fotokopilot/tree/main/docs#wp-fotokopilot-developer-documentation)._ + +
How this README is built
diff --git a/banner-1544x500.png b/banner-1544x500.png new file mode 100644 index 0000000..7e76525 Binary files /dev/null and b/banner-1544x500.png differ diff --git a/build_readme.py b/build_readme.py index 6a9adda..c5d163c 100644 --- a/build_readme.py +++ b/build_readme.py @@ -1,7 +1,3 @@ -from python_graphql_client import GraphqlClient -import feedparser -import tweepy -import time import httpx import json import pathlib @@ -9,9 +5,6 @@ import os root = pathlib.Path(__file__).parent.resolve() -client = GraphqlClient(endpoint="https://api.github.com/graphql") - - TOKEN = os.environ.get("SODERLIND_TOKEN", "") @@ -27,75 +20,139 @@ def replace_chunk(content, marker, chunk, inline=False): return r.sub(chunk, content) -def make_query(after_cursor=None): - return """ -query { - viewer { - repositories(first: 100, orderBy: {field:PUSHED_AT, direction:DESC}, privacy: PUBLIC, isFork: false,after:AFTER) { - pageInfo {hasNextPage, endCursor} - nodes { - name - description - pushedAt - url - forkCount - } - } - } -} -""".replace( - "AFTER", '"{}"'.format(after_cursor) if after_cursor else "null" - ) +def get_repo_info(url): + """Fetch repo info from GitHub API (stars, description).""" + # Extract owner/repo from URL + parts = url.rstrip('/').split('/') + owner, repo = parts[-2], parts[-1] + api_url = f"https://api.github.com/repos/{owner}/{repo}" + + headers = {} + if TOKEN: + headers["Authorization"] = f"Bearer {TOKEN}" + + try: + response = httpx.get(api_url, timeout=10, headers=headers) + if response.status_code == 200: + data = response.json() + return { + "stars": data.get("stargazers_count", 0), + "description": data.get("description", ""), + "name": data.get("name", repo), + } + except Exception: + pass + + return {"stars": 0, "description": "", "name": repo} + +def get_title_from_repo(url, custom_title=None): + """Get display title from custom title or repo name.""" + if custom_title: + return custom_title + # Extract repo name and convert to title case + repo_name = url.rstrip('/').split('/')[-1] + return repo_name.replace('-', ' ').title() -def fetch_plugins(oauth_token): - plugins = [] - has_next_page = True - after_cursor = None - while has_next_page: - data = client.execute( - query=make_query(after_cursor), - headers={"Authorization": "Bearer {}".format(oauth_token)}, - ) - print() - print(json.dumps(data, indent=4)) - print() - i = 0 - # for i, repo in enumerate(data["data"]["viewer"]["repositories"]["nodes"]): - for repo in data["data"]["viewer"]["repositories"]["nodes"]: - plugin_url = repo["url"] + "/blob/master/" + repo["name"] + ".php" - if len(str(repo["description"])) > 4 and httpx.get(plugin_url).status_code == 200: - plugins.append( - { - "repo": repo["name"], - "url": repo["url"], - "font_format": "**" if i % 2 == 0 else "*", - "description": repo["description"].strip(), - "pushed_at": repo["pushedAt"], - "fork_count": repo["forkCount"], - } - ) - i = i+1 - has_next_page = data["data"]["viewer"]["repositories"]["pageInfo"][ - "hasNextPage" - ] - after_cursor = data["data"]["viewer"]["repositories"]["pageInfo"]["endCursor"] - return plugins +def load_plugins(): + """Load plugins from repo.json.""" + repo_json = root / "repo.json" + with repo_json.open() as f: + return json.load(f) + + +def build_markdown(plugins): + """Build the markdown table with parent/child structure.""" + md_parts = [] + md_parts.append("") + md_parts.append("") + + for parent in plugins: + # Get parent repo info (only if URL exists) + parent_url = parent.get("url") + if parent_url: + parent_info = get_repo_info(parent_url) + parent_title = get_title_from_repo(parent_url, parent.get("title")) + parent_desc = parent.get("description") or parent_info["description"] + parent_stars = parent_info["stars"] + else: + parent_title = parent.get("title", "Plugins") + parent_desc = parent.get("description", "") + parent_stars = 0 + + # Parent row (full width) + stars_display = f" ⭐ {parent_stars}" if parent_stars > 0 else "" + md_parts.append("") + md_parts.append('") + md_parts.append("") + + # Children in two columns (sorted alphabetically by title) + children = parent.get("children", []) + children = sorted(children, key=lambda c: get_title_from_repo(c["url"], c.get("title")).lower()) + for i in range(0, len(children), 2): + md_parts.append("") + + # Left column + child = children[i] + child_info = get_repo_info(child["url"]) + child_title = get_title_from_repo(child["url"], child.get("title")) + child_desc = child.get("description") or child_info["description"] + child_stars = child_info["stars"] + stars_display = f" ⭐ {child_stars}" if child_stars > 0 else "" + + md_parts.append('") + + # Right column (if exists) + if i + 1 < len(children): + child = children[i + 1] + child_info = get_repo_info(child["url"]) + child_title = get_title_from_repo(child["url"], child.get("title")) + child_desc = child.get("description") or child_info["description"] + child_stars = child_info["stars"] + stars_display = f" ⭐ {child_stars}" if child_stars > 0 else "" + + md_parts.append('") + else: + md_parts.append('') + + md_parts.append("") + + md_parts.append("
') + md_parts.append("") + if parent_url: + md_parts.append(f'### {parent_title}{stars_display}') + else: + md_parts.append(f'### {parent_title}') + md_parts.append("") + if parent_desc: + md_parts.append(f"{parent_desc}") + md_parts.append("") + md_parts.append("
') + md_parts.append("") + md_parts.append(f'**{child_title}**{stars_display}') + md_parts.append("") + if child_desc: + md_parts.append(f"{child_desc}") + md_parts.append("") + md_parts.append("') + md_parts.append("") + md_parts.append(f'**{child_title}**{stars_display}') + md_parts.append("") + if child_desc: + md_parts.append(f"{child_desc}") + md_parts.append("") + md_parts.append("
") + md_parts.append("") + return "\n".join(md_parts) if __name__ == "__main__": readme = root / "README.md" - plugins = fetch_plugins(TOKEN) - plugins.sort(key=lambda r: r["pushed_at"], reverse=True) - md = "\n".join( - [ - "[{font_format}{description}{font_format}]({url})    ".format( - **plugin) - for plugin in plugins[:20] - ] - ) + plugins = load_plugins() + md = build_markdown(plugins) readme_contents = readme.open().read() rewritten = replace_chunk(readme_contents, "plugins", md) + readme.open("w").write(rewritten) readme.open("w").write(rewritten) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..a36be20 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,129 @@ +# How the README is Built + +The main [README.md](../README.md) is **auto-generated** from data in [repo.json](../repo.json). A Python script reads the JSON, fetches live star counts from GitHub, and writes a formatted HTML table into the README. + +## Overview + +``` +repo.json ──► build_readme.py ──► README.md + │ + GitHub API (star counts, descriptions) +``` + +1. **repo.json** — you define your repos and how they're grouped. +2. **build_readme.py** — reads `repo.json`, calls the GitHub API for each repo, and builds an HTML ``. +3. **README.md** — the script replaces everything between the marker comments: + ```html + + ...generated table... + + ``` + Content outside those markers is untouched. + +## repo.json Syntax + +The file is a **JSON array of groups**. Each group becomes a section heading in the table, and its children become two-column rows beneath it. + +### Group Object + +| Field | Required | Description | +|---------------|----------|------------------------------------------------------------------------------------------------------| +| `title` | Yes | Section heading shown in the README (e.g. `"WordPress Multisite Plugins"`). Supports emoji shortcodes like `:construction:`. | +| `description` | No | Text displayed below the heading. Can contain HTML. Leave as `""` to omit. | +| `url` | No | GitHub repo URL for the group itself. If set, the heading becomes a link **and** stars are fetched. | +| `children` | No | Array of child repo objects (see below). Omit or use `[]` for a heading-only row. | + +### Child Object + +| Field | Required | Description | +|---------------|----------|---------------------------------------------------------------------------------------------------------------------| +| `url` | **Yes** | Full GitHub URL (e.g. `"https://github.com/soderlind/wp-loupe"`). | +| `title` | No | Display name. If omitted, derived from the repo name: hyphens → spaces → Title Case (`wp-loupe` → `"Wp Loupe"`). | +| `description` | No | Short description. If omitted or `""`, the repo description is fetched from the GitHub API automatically. | + +### Minimal Example + +```json +[ + { + "title": "My Plugins", + "description": "A collection of useful plugins.", + "children": [ + { + "url": "https://github.com/user/plugin-one" + }, + { + "url": "https://github.com/user/plugin-two", + "title": "Plugin Two", + "description": "Custom description here." + } + ] + } +] +``` + +This produces: + +| Output | Detail | +|-------------------------|---------------------------------------------------------------| +| **Section heading** | "My Plugins" with the description underneath | +| **plugin-one** | Title auto-generated as "Plugin One", description from GitHub | +| **Plugin Two** | Uses the custom title and description | + +Children are **sorted alphabetically** by title and laid out in a **two-column** grid. + +### Group with its own Repo URL + +If the group itself is a repo, add `url`: + +```json +{ + "title": "Virtual Media Folders", + "url": "https://github.com/soderlind/virtual-media-folders", + "description": "Organize your media library into virtual folders.", + "children": [ ... ] +} +``` + +The heading becomes a clickable link and the group's star count is shown. + +## Running the Build + +```bash +# Install dependencies (first time only) +pip install -r requirements.txt + +# Set a GitHub token to avoid API rate limits (optional but recommended) +export SODERLIND_TOKEN="ghp_..." + +# Rebuild the README +python build_readme.py +``` + +The script modifies `README.md` **in place** — only the region between the `` / `` markers is rewritten. + +## GitHub Actions Automation + +The README is rebuilt automatically by a GitHub Actions workflow defined in [`.github/workflows/update-plugins.yml`](../.github/workflows/update-plugins.yml). It runs in three situations: + +| Trigger | When | +|----------------------|-------------------------------------------------------------| +| **Push** | Any commit that changes `repo.json` | +| **Schedule (cron)** | Every day at 07:00 UTC — keeps star counts fresh | +| **Manual** | On-demand via the "Run workflow" button in the Actions tab | + +### What the workflow does + +1. Checks out the repo. +2. Sets up Python and installs `httpx`. +3. Runs `python build_readme.py` with `GITHUB_TOKEN` so API calls are authenticated. +4. If the README changed, commits and pushes automatically (as `github-actions` bot). + +Because of this automation, you typically only need to **edit `repo.json` and push** — the workflow takes care of the rest. + +## Tips + +- **Empty description** (`""`) → the script fetches the description from the GitHub API. +- **Custom description** → overrides the API description; can contain HTML. +- **No `title` on a child** → title is auto-derived from the repo URL slug. +- **Star counts** are always live from the GitHub API; they are not stored in `repo.json`. diff --git a/ralph-luv-wp-header.png b/ralph-luv-wp-header.png new file mode 100644 index 0000000..7cebfaa Binary files /dev/null and b/ralph-luv-wp-header.png differ diff --git a/ralph-luv-wp.jpg b/ralph-luv-wp.jpg new file mode 100644 index 0000000..feac78d Binary files /dev/null and b/ralph-luv-wp.jpg differ diff --git a/repo.json b/repo.json index a91a285..95b4f4e 100644 --- a/repo.json +++ b/repo.json @@ -1,50 +1,206 @@ [ { - "url": "https://github.com/soderlind/additional-javascript", - "description": "" + "title": "Virtual Media Folders", + "description": "

Virtual Media Folders brings folder organization to your WordPress Media Library. Organize your media files into hierarchical folders without moving files on disk. Folders are virtual, so your URLs never change. Also available on WordPress.org.

Add-ons available:

", + "children": [ + { + "url": "https://github.com/soderlind/vmfa", + "title": "Add-On Manager", + "description": "🚀 Install and manage add-ons that extend Virtual Media Folders." + }, + { + "url": "https://github.com/soderlind/vmfa-ai-organizer", + "title": "AI Organizer", + "description": "Uses vision-capable AI models to analyze actual image content and automatically organize your media library into virtual folders. This is add-on functionality requiring an API key from a supported AI service provider, or a local LLM." + }, + { + "url": "https://github.com/soderlind/vmfa-rules-engine", + "title": "Rules Engine", + "description": "Rule-based automatic folder assignment for media uploads, based on metadata, file type, or other criteria. " + }, + { + "url": "https://github.com/soderlind/vmfa-editorial-workflow", + "title": "Editorial Workflow", + "description": "Role-based folder access, move restrictions, and Inbox workflow for Virtual Media Folders." + }, + { + "url": "https://github.com/soderlind/vmfa-media-cleanup", + "title": "Media Cleanup", + "description": "Tools to identify and clean up unused or duplicate media files." + }, + { + "url": "https://github.com/soderlind/vmfa-folder-exporter", + "title": "Folder Exporter", + "description": "Export folders (or subtrees) as ZIP archives with optional CSV manifests." + }, + { + "url": "https://github.com/soderlind/vmfa-migrate", + "title": "Migrate", + "description": "Import folders and file assignments from other media folder plugins. Supports FileBird, Enhanced Media Library, Real Media Library,HappyFiles, WP Media Folder, CatFolders and Media Library Assistant." + }, + { + "url": "https://github.com/soderlind/vmfa-ai-ability", + "title": "AI Ability", + "description": "" + } + ] }, { - "url": "https://github.com/soderlind/ai-alt-text", - "description": "" + "title": ":robot: AI Experiments :robot:", + "description": "Some are in early beta, while others are production ready, and all explore how AI can be used in WordPress.", + "children": [ + { + "url": "https://github.com/soderlind/admin-coach-tours", + "description": "" + }, + { + "url": "https://github.com/soderlind/ps-design-system", + "title": "PS Design System", + "description": "" + }, + { + "url": "https://github.com/soderlind/talking-head", + "title": "Talking Head", + "description": "" + }, + { + "url": "https://github.com/soderlind/ai-alt-text", + "title": "AI Alt Text", + "description": "" + }, + { + "url": "https://github.com/soderlind/content-poll", + "description": "" + }, + { + "url": "https://github.com/soderlind/ai-valve", + "title": "AI Valve", + "description": "" + }, + { + "url": "https://github.com/soderlind/ai-provider-for-azure-ai-foundry", + "title": "AI Provider for Azure AI Foundry", + "description": "

Connect WordPress 7.0+ to Azure AI Foundry for text generation, image generation, embeddings, and more.

Also available on WordPress.org.

" + }, + { + "url": "https://github.com/soderlind/ai-router", + "title": "AI Router", + "description": "" + }, + { + "url": "https://github.com/soderlind/ai-provider-for-exo", + "title": "AI Provider for exo", + "description": "

Connect WordPress to exo, run frontier AI models locally on your device cluster.

Also available on WordPress.org.

" + }, + { + "url": "https://github.com/soderlind/llms-md", + "title": "LLMS MD", + "description": "" + } + ] }, { - "url": "https://github.com/soderlind/all-sites-cron", - "description": "" + "title": "WordPress Multisite Plugins", + "description": "", + "children": [ + { + "url": "https://github.com/soderlind/super-admin-switch-to-admin", + "description": "" + }, + { + "url": "https://github.com/soderlind/all-sites-cron", + "description": "" + }, + { + "url": "https://github.com/soderlind/multisite-exporter", + "description": "" + }, + { + "url": "https://github.com/soderlind/super-admin-all-sites-menu", + "title": "Super Admin All Sites Menu", + "description": "

For the super admin, replace WP Admin Bar My Sites menu with an All Sites menu.

Also available on WordPress.org.

" + }, + { + "url": "https://github.com/soderlind/network-style-override", + "title": "Network Style Override", + "description": "" + } + ] }, { - "url": "https://github.com/soderlind/content-poll", - "description": "" - }, - { - "url": "https://github.com/soderlind/custom-document-folder", - "description": "" - }, - { - "url": "https://github.com/soderlind/editor-can-manage-privacy-options", - "description": "" - }, - { - "url": "https://github.com/soderlind/multisite-exporter", - "description": "" - }, - { - "url": "https://github.com/soderlind/read-offline", - "description": "" - }, - { - "url": "https://github.com/soderlind/redis-queue", - "description": "" - }, - { - "url": "https://github.com/soderlind/super-admin-switch-to-admin", - "description": "" - }, - { - "url": "https://github.com/soderlind/virtual-media-folders", - "description": "" - }, - { - "url": "https://github.com/soderlind/wp-loupe", - "description": "" + "title": "Miscellaneous WordPress Plugins", + "description": "", + "children": [ + { + "url": "https://github.com/soderlind/read-offline", + "description": "" + }, + { + "url": "https://github.com/soderlind/wp-loupe", + "title": "WP Loupe", + "description": "" + }, + { + "url": "https://github.com/soderlind/wp-loupe-admin-search", + "title": "WP Loupe - Admin Search Add-On", + "description": "" + }, + { + "url": "https://github.com/soderlind/gf-html-area-editor", + "title": "Gravity Forms HTML Area Editor", + "description": "" + }, + { + "url": "https://github.com/soderlind/custom-document-folder", + "description": "" + }, + { + "url": "https://github.com/soderlind/editor-can-manage-privacy-options", + "description": "" + }, + { + "url": "https://github.com/soderlind/redis-queue", + "description": "" + }, + { + "url": "https://github.com/soderlind/passwp-posts", + "title": "PassWP - Posts", + "description": "" + }, + { + "url": "https://github.com/soderlind/additional-javascript", + "description": "" + }, + { + "url": "https://github.com/soderlind/cm-beautiful", + "title": "Color Me Beautiful", + "description": "" + }, + { + "url": "https://github.com/soderlind/wp-portable-text", + "title": "WP Portable Text", + "description": "" + }, + { + "url": "https://github.com/soderlind/tec-sort-by-end-date", + "title": "TEC Sort by End Date", + "description": "" + }, + { + "url": "https://github.com/soderlind/ps-hyphenate", + "title": "PS Hyphenate", + "description": "" + }, + { + "url": "https://github.com/soderlind/push-notifications-for-trigv", + "title": "Push Notifications for Trigv", + "description": "" + }, + { + "url": "https://github.com/soderlind/cache-tags-for-cloudflare", + "title": "Cache Tags for Cloudflare", + "description": "

Precise Cloudflare cache purging for WordPress: adds Cache-Tag headers and purges only affected posts, pages, and terms.

Also available on WordPress.org.

" + } + ] } ] \ No newline at end of file diff --git a/update_plugins_readme.py b/update_plugins_readme.py deleted file mode 100644 index 2853a4f..0000000 --- a/update_plugins_readme.py +++ /dev/null @@ -1,183 +0,0 @@ -import httpx -import json -import pathlib -import re -import os - -root = pathlib.Path(__file__).parent.resolve() - -TOKEN = os.environ.get("GITHUB_TOKEN", "") - - -def replace_chunk(content, marker, chunk, inline=False): - """Replace content between marker comments.""" - r = re.compile( - r".*".format(marker, marker), - re.DOTALL, - ) - if not inline: - chunk = "\n{}\n".format(chunk) - chunk = "{}".format(marker, chunk, marker) - return r.sub(chunk, content) - - -def extract_repo_info(url): - """Extract owner and repo name from GitHub URL.""" - # https://github.com/soderlind/repo-name -> (soderlind, repo-name) - parts = url.rstrip("/").split("/") - return parts[-2], parts[-1] - - -def fetch_repo_info(owner, repo, token): - """Fetch repository description, stars and creation date from GitHub API.""" - headers = {} - if token: - headers["Authorization"] = f"Bearer {token}" - headers["Accept"] = "application/vnd.github.v3+json" - - url = f"https://api.github.com/repos/{owner}/{repo}" - response = httpx.get(url, headers=headers) - - if response.status_code == 200: - data = response.json() - return { - "description": data.get("description", "") or "", - "stars": data.get("stargazers_count", 0), - "created_at": data.get("created_at", "") - } - return {"description": "", "stars": 0, "created_at": ""} - - -def format_repo_name(repo_name): - """Format repo name for display (replace hyphens with spaces, title case).""" - return repo_name.replace("-", " ").title() - - -def generate_html_table(repos): - """Generate HTML table with two columns of dl/dt/dd elements. - Newest repo (is_new) is promoted to a top row spanning both columns. - Adds ⭐ star count (if >0) and 🚀 rocket for the newest repo. - """ - rows = [] - - # Separate newest repo from the rest - newest_repo = None - other_repos = [] - for repo in repos: - if repo.get("is_new"): - newest_repo = repo - else: - other_repos.append(repo) - - # Helper to build cell HTML - def build_cell(repo): - stars = repo.get("stars", 0) - rocket = repo.get("is_new", False) - rocket_text = " 🚀" if rocket else "" - stars_text = f" ⭐ {stars}" if stars > 0 else "" - return ( - f'
\n' - f'
{repo["name"]}{rocket_text}{stars_text}
\n' - f'
{repo["description"]}
\n' - f'
' - ) - - # Top row for newest repo (centered, left-aligned text via align attribute) - if newest_repo: - cell_html = build_cell(newest_repo) - rows.append( - f'\n\n' - ) - - # Remaining repos in two-column layout - for i in range(0, len(other_repos), 2): - row_cells = [] - for j in range(2): - if i + j < len(other_repos): - repo = other_repos[i + j] - cell = build_cell(repo) - row_cells.append(f'') - else: - row_cells.append('') - rows.append(f'\n{row_cells[0]}\n{row_cells[1]}\n') - - return '
\n
\n{cell_html}\n
\n
\n{cell}\n
\n' + '\n'.join(rows) + '\n
' - - -def main(): - # Read repo.json - repo_json_path = root / "repo.json" - with open(repo_json_path) as f: - repo_data = json.load(f) - - # Process each repo - repos = [] - created_dates = [] - for item in repo_data: - url = item["url"] - owner, repo_name = extract_repo_info(url) - - # Fetch repo info from API (description and stars) - print(f"Fetching info for {owner}/{repo_name}...") - repo_info = fetch_repo_info(owner, repo_name, TOKEN) - - # Use description from JSON if provided, otherwise use API response - description = item.get("description", "").strip() - if not description: - description = repo_info["description"] - - # Compute age to decide if new (<30 days) - created_at = repo_info.get("created_at", "") - is_new = False - created_dt = None - if created_at: - # created_at example: 2025-11-05T12:34:56Z - try: - from datetime import datetime, timezone - created_dt = datetime.strptime(created_at, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) - created_dates.append(created_dt) - except Exception: - is_new = False - - repos.append({ - "url": url, - "name": format_repo_name(repo_name), - "description": description or "No description available.", - "stars": repo_info["stars"], - "is_new": is_new, - "_created_dt": created_dt, - }) - - # Mark only the single newest repo with rocket - try: - newest_dt = None - for r in repos: - if r.get("_created_dt") is not None: - if newest_dt is None or r["_created_dt"] > newest_dt: - newest_dt = r["_created_dt"] - if newest_dt is not None: - # Clear all is_new flags, then set only the newest - for r in repos: - r["is_new"] = False - for r in repos: - if r.get("_created_dt") == newest_dt: - r["is_new"] = True - break - except Exception: - pass - - # Generate HTML table - html_table = generate_html_table(repos) - - # Update README.md - readme_path = root / "README.md" - readme_contents = readme_path.read_text() - - rewritten = replace_chunk(readme_contents, "plugins", html_table) - readme_path.write_text(rewritten) - - print(f"Updated README.md with {len(repos)} plugins.") - - -if __name__ == "__main__": - main() diff --git a/vscode.jpg b/vscode.jpg new file mode 100644 index 0000000..2199e4e Binary files /dev/null and b/vscode.jpg differ diff --git a/wp-fotokopilot-some.jpg b/wp-fotokopilot-some.jpg new file mode 100644 index 0000000..ef9fded Binary files /dev/null and b/wp-fotokopilot-some.jpg differ diff --git a/wp-skills.jpg b/wp-skills.jpg new file mode 100644 index 0000000..9d7cae8 Binary files /dev/null and b/wp-skills.jpg differ