Geofeed Tuner
GitHubOfficialCreated by Sid MathurDistributed by GitHub
- Skill
- Tool integration
- Automation and integrations
- Apache-2.0
- No issues found
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
Geofeed Tuner is a structured workflow designed for network operators, ISPs, and cloud providers to manage their public IP geolocation data. It enforces RFC 8805 standards while providing opinionated best practices to improve data accuracy and quality. The skill guides users through a six-phase pipeline, including structural validation, IP prefix analysis, and geolocation quality checks, resulting in a comprehensive HTML tuning report.
What it does
- Validates CSV files against RFC 8805 standards.
- Checks IP prefixes for syntax errors and non-public address space.
- Validates ISO 3166-1 country codes and ISO 3166-2 region codes.
- Identifies and flags deprecated postal code fields.
- Generates detailed HTML reports for feed improvement.
- Provides actionable suggestions for geolocation granularity.
When to use it
- When creating a new self-published IP geolocation feed.
- When troubleshooting or validating an existing CSV geofeed.
- When preparing IP geolocation data for publication by network operators.
- When ensuring compliance with industry-standard geolocation feed formats.
Advantages
- Ensures strict adherence to RFC 8805 requirements.
- Automates the detection of common formatting and data quality errors.
- Provides clear, actionable feedback for improving geolocation accuracy.
- Maintains a clean separation between input data and generated reports.
Usage examples
- “I need to validate my ISP's IP geolocation CSV file for RFC 8805 compliance.”
- The agent will initiate the six-phase pipeline, starting with input gathering and structural validation to identify any errors or warnings in the provided CSV.
- “Can you help me tune my geofeed to improve accuracy?”
- The agent will analyze the current feed, apply validation rules, and generate an HTML report with specific suggestions for refining country, region, and city data.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/github/geofeed-tuner/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip geofeed-tuner.zip -d .agents/skills
Documentation by Sid Mathur
Geofeed Tuner – Create Better IP Geolocation Feeds
This skill helps you create and improve IP geolocation feeds in CSV format by:
Ensuring your CSV is well-formed and consistent
Checking alignment with RFC 8805 (the industry standard)
Applying opinionated best practices learned from real-world deployments
Suggesting improvements for accuracy, completeness, and privacy
When to Use This Skill
Use this skill when a user asks for help creating, improving, or publishing an IP geolocation feed file in CSV format.
Use it to tune and troubleshoot CSV geolocation feeds — catching errors, suggesting improvements, and ensuring real-world usability beyond RFC compliance.
Intended audience:
Network operators, administrators, and engineers responsible for publicly routable IP address space
Organizations such as ISPs, mobile carriers, cloud providers, hosting and colocation companies, Internet Exchange operators, and satellite internet providers
Do not use this skill for private or internal IP address management; it applies only to publicly routable IP addresses.
Prerequisites
Python 3 is required.
Directory Structure and File Management
This skill uses a clear separation between distribution files (read-only) and working files (generated at runtime).
Read-Only Directories (Do Not Modify)
The following directories contain static distribution assets. Do not create, modify, or delete files in these directories:
| Directory | Purpose |
|---|---|
assets/ | Static data files (ISO codes, examples) |
references/ | RFC specifications and code snippets for reference |
scripts/ | Executable code and HTML template files for reports |
Working Directories (Generated Content)
All generated, temporary, and output files go in these directories:
| Directory | Purpose |
|---|---|
run/ | Working directory for all agent-generated content |
run/data/ | Downloaded CSV files from remote URLs |
run/report/ | Generated HTML tuning reports |
File Management Rules
Never write to
assets/,references/, orscripts/— these are part of the skill distribution and must remain unchanged.All downloaded input files (from remote URLs) must be saved to
./run/data/.All generated HTML reports must be saved to
./run/report/.All generated Python scripts must be saved to
./run/.The
run/directory may be cleared between sessions; do not store permanent data there.Working directory for execution: All generated scripts in
./run/must be executed with the skill root directory (the directory containingSKILL.md) as the current working directory, so that relative paths likeassets/iso3166-1.jsonand./run/data/report-data.jsonresolve correctly. Do notcdinto./run/before running scripts.
Processing Pipeline: Sequential Phase Execution
All phases must be executed in order, from Phase 1 through Phase 6. Each phase depends on the successful completion of the previous phase. For example, structure checks must complete before quality analysis can run.
The phases are summarized below. The agent must follow the detailed steps outlined further in each phase section.
| Phase | Name | Description |
|---|---|---|
| 1 | Understand the Standard | Review the key requirements of RFC 8805 for self-published IP geolocation feeds |
| 2 | Gather Input | Collect IP subnet data from local files or remote URLs |
| 3 | Checks & Suggestions | Validate CSV structure, analyze IP prefixes, and check data quality |
| 4 | Tuning Data Lookup | Use Fastah's MCP tool to retrieve tuning data for improving geolocation accuracy |
| 5 | Generate Tuning Report | Create an HTML report summarizing the analysis and suggestions |
| 6 | Final Review | Verify consistency and completeness of the report data |
Do not skip phases. Each phase provides critical checks or data transformations required by subsequent stages.
Execution Plan Rules
Before executing each phase, the agent MUST generate a visible TODO checklist.
The plan MUST:
Appear at the very start of the phase
List every step in order
Use a checkbox format
Be updated live as steps complete
Phase 1: Understand the Standard
The key requirements from RFC 8805 that this skill enforces are summarized below. Use this summary as your working reference. Only consult the full RFC 8805 text for edge cases, ambiguous situations, or when the user asks a standards question not covered here.
RFC 8805 Key Facts
Purpose: A self-published IP geolocation feed lets network operators publish authoritative location data for their IP address space in a simple CSV format, allowing geolocation providers to incorporate operator-supplied corrections.
CSV Column Order (Sections 2.1.1.1–2.1.1.5):
| Column | Field | Required | Notes |
|---|---|---|---|
| 1 | ip_prefix | Yes | CIDR notation; IPv4 or IPv6; must be a network address |
| 2 | alpha2code | No | ISO 3166-1 alpha-2 country code; empty or "ZZ" = do-not-geolocate |
| 3 | region | No | ISO 3166-2 subdivision code (e.g., US-CA) |
| 4 | city | No | Free-text city name; no authoritative validation set |
| 5 | postal_code | No | Deprecated — must be left empty or absent |
Structural rules:
Files may contain comment lines beginning with
#(including the header, if present).A header row is optional; if present, it is treated as a comment if it starts with
#.Files must be encoded in UTF-8.
Subnet host bits must not be set (i.e.,
192.168.1.1/24is invalid; use192.168.1.0/24).Applies only to globally routable unicast addresses — not private, loopback, link-local, or multicast space.
Do-not-geolocate: An entry with an empty alpha2code or case-insensitive ZZ (irrespective of values of region/city) is an explicit signal that the operator does not want geolocation applied to that prefix.
Postal codes deprecated (Section 2.1.1.5): The fifth column must not contain postal or ZIP codes. They are too fine-grained for IP-range mapping and raise privacy concerns.
Phase 2: Gather Input
If the user has not already provided a list of IP subnets or ranges (sometimes referred to as
inetnumorinet6num), prompt them to supply it. Accepted input formats:Text pasted into the chat
A local CSV file
A remote URL pointing to a CSV file
If the input is a remote URL:
Attempt to download the CSV file to
./run/data/before processing.On HTTP error (4xx, 5xx, timeout, or redirect loop), stop immediately and report to the user:
Feed URL is not reachable: HTTP {status_code}. Please verify the URL is publicly accessible.Do not proceed to Phase 3 with an incomplete or empty download.
If the input is a local file, process it directly without downloading.
Encoding detection and normalization:
Attempt to read the file as UTF-8 first.
If a
UnicodeDecodeErroris raised, tryutf-8-sig(UTF-8 with BOM), thenlatin-1.Once successfully decoded, re-encode and write the working copy as UTF-8.
If no encoding succeeds, stop and report:
Unable to decode input file. Please save it as UTF-8 and try again.
Phase 3: Checks & Suggestions
Execution Rules
Generate a script for this phase.
Do NOT combine this phase with others.
Do NOT precompute future-phase data.
Store the output as a JSON file at:
./run/data/report-data.json
Schema Definition
The JSON structure below is IMMUTABLE during Phase 3. Phase 4 will later add a TunedEntry object to each object in Entries — this is the only permitted schema extension and happens in a separate phase.
JSON keys map directly to template placeholders like {{.CountryCode}}, {{.HasError}}, etc.
{
"InputFile": "",
"Timestamp": 0,
"TotalEntries": 0,
"IpV4Entries": 0,
"IpV6Entries": 0,
"InvalidEntries": 0,
"Errors": 0,
"Warnings": 0,
"OK": 0,
"Suggestions": 0,
"CityLevelAccuracy": 0,
"RegionLevelAccuracy": 0,
"CountryLevelAccuracy": 0,
"DoNotGeolocate": 0,
"Entries": [
{
"Line": 0,
"IPPrefix": "",
"CountryCode": "",
"RegionCode": "",
"City": "",
"Status": "",
"IPVersion": "",
"Messages": [
{
"ID": "",
"Type": "",
"Text": "",
"Checked": false
}
],
"HasError": false,
"HasWarning": false,
"HasSuggestion": false,
"DoNotGeolocate": false,
"GeocodingHint": "",
"Tunable": false
}
]
}Field definitions:
Top-level metadata:
InputFile: The original input source, either a local filename or a remote URL.Timestamp: Milliseconds since Unix epoch when the tuning was performed.TotalEntries: Total number of data rows processed (excluding comment and blank lines).IpV4Entries: Count of entries that are IPv4 subnets.IpV6Entries: Count of entries that are IPv6 subnets.InvalidEntries: Count of entries that failed IP prefix parsing and CSV parsing.Errors: Total entries whoseStatusisERROR.Warnings: Total entries whoseStatusisWARNING.OK: Total entries whoseStatusisOK.Suggestions: Total entries whoseStatusisSUGGESTION.CityLevelAccuracy: Count of valid entries whereCityis non-empty.RegionLevelAccuracy: Count of valid entries whereRegionCodeis non-empty andCityis empty.CountryLevelAccuracy: Count of valid entries whereCountryCodeis non-empty,RegionCodeis empty, andCityis empty.DoNotGeolocate(metadata): Count of valid entries whereCountryCode,RegionCode, andCityare all empty.
Entry fields:
Entries: Array of objects, one per data row, with the following per-entry fields:Line: 1-based line number in the original CSV (counting all lines including comments and blanks).IPPrefix: The normalized IP prefix in CIDR slash notation.CountryCode: The ISO 3166-1 alpha-2 country code, or empty string.RegionCode: The ISO 3166-2 region code (e.g.,US-CA), or empty string.City: The city name, or empty string.Status: Highest severity assigned:ERROR>WARNING>SUGGESTION>OK.IPVersion:"IPv4"or"IPv6"based on the parsed IP prefix.Messages: Array of message objects, each with:ID: String identifier from the Validation Rules Reference table below (e.g.,"1101","3301").Type: The severity type:"ERROR","WARNING", or"SUGGESTION".Text: The human-readable validation message string.Checked:trueif the validation rule is auto-tunable (Tunable: truein the reference table),falseotherwise. Controls whether the checkbox in the report ischeckedordisabled.
HasError:trueif any message hasType"ERROR".HasWarning:trueif any message hasType"WARNING".HasSuggestion:trueif any message hasType"SUGGESTION".DoNotGeolocate(entry):trueifCountryCodeis empty or"ZZ"— the entry is an explicit do-not-geolocate signal.GeocodingHint: Always empty string""in Phase 3. Reserved for future use.Tunable:trueif any message in the entry hasChecked: true. Computed as logical OR across all messages'Checkedvalues. This flag drives the "Tune" button visibility in the report.
Validation Rules Reference
When adding messages to an entry, use the ID, Type, Text, and Checked values from this table.
| ID | Type | Text | Checked | Condition Reference |
|---|---|---|---|---|
1101 | ERROR | IP prefix is empty | false | IP Prefix Analysis: empty |
1102 | ERROR | Invalid IP prefix: unable to parse as IPv4 or IPv6 network | false | IP Prefix Analysis: invalid syntax |
1103 | ERROR | Non-public IP range is not allowed in an RFC 8805 feed | false | IP Prefix Analysis: non-public |
3101 | SUGGESTION | IPv4 prefix is unusually large and may indicate a typo | false | IP Prefix Analysis: IPv4 < /22 |
3102 | SUGGESTION | IPv6 prefix is unusually large and may indicate a typo | false | IP Prefix Analysis: IPv6 < /64 |
1201 | ERROR | Invalid country code: not a valid ISO 3166-1 alpha-2 value | true | Country Code Analysis: invalid |
1301 | ERROR | Invalid region format; expected COUNTRY-SUBDIVISION (e.g., US-CA) | true | Region Code Analysis: bad format |
1302 | ERROR | Invalid region code: not a valid ISO 3166-2 subdivision | true | Region Code Analysis: unknown code |
1303 | ERROR | Region code does not match the specified country code | true | Region Code Analysis: mismatch |
1401 | ERROR | Invalid city name: placeholder value is not allowed | false | City Name Analysis: placeholder |
1402 | ERROR | Invalid city name: abbreviated or code-based value detected | true | City Name Analysis: abbreviation |
2401 | WARNING | City name formatting is inconsistent; consider normalizing the value | true | City Name Analysis: formatting |
1501 | ERROR | Postal codes are deprecated by RFC 8805 and must be removed for privacy reasons | true | Postal Code Check |
3301 | SUGGESTION | Region is usually unnecessary for small territories; consider removing the region value | true | Tuning: small territory region |
3402 | SUGGESTION | City-level granularity is usually unnecessary for small territories; consider removing the city value | true | Tuning: small territory city |
3303 | SUGGESTION | Region code is recommended when a city is specified; choose a region from the dropdown | true | Tuning: missing region with city |
3104 | SUGGESTION | Confirm whether this subnet is intentionally marked as do-not-geolocate or missing location data | true | Tuning: unspecified geolocation |
Populating Messages
When a validation check matches, add a message to the entry's Messages array using the values from the reference table:
entry["Messages"].append({
"ID": "1201", # From the table
"Type": "ERROR", # From the table
"Text": "Invalid country code: not a valid ISO 3166-1 alpha-2 value", # From the table
"Checked": True # From the table (True = tunable)
})After populating all messages for an entry, derive the entry-level flags:
entry["HasError"] = any(m["Type"] == "ERROR" for m in entry["Messages"])
entry["HasWarning"] = any(m["Type"] == "WARNING" for m in entry["Messages"])
entry["HasSuggestion"] = any(m["Type"] == "SUGGESTION" for m in entry["Messages"])
entry["Tunable"] = any(m["Checked"] for m in entry["Messages"])Accuracy Level Counting Rules
Accuracy levels are mutually exclusive. Assign each valid (non-ERROR, non-invalid) entry to exactly one bucket based on the most granular non-empty geo field:
| Condition | Bucket |
|---|---|
City is non-empty | CityLevelAccuracy |
RegionCode non-empty AND City is empty | RegionLevelAccuracy |
CountryCode non-empty, RegionCode and City empty | CountryLevelAccuracy |
DoNotGeolocate (entry) is true | DoNotGeolocate (metadata) |
Do not count entries with HasError: true or entries in InvalidEntries in any accuracy bucket.
The agent MUST NOT:
Rename fields
Add or remove fields
Change data types
Reorder keys
Alter nesting
Wrap the object
Split into multiple files
If a value is unknown, leave it empty — never invent data.
Structure & Format Check
This phase verifies that your feed is well-formed and parseable. Critical structural errors must be resolved before the tuner can analyze geolocation quality.
CSV Structure
This subsection defines rules for CSV-formatted input files used for IP geolocation feeds. The goal is to ensure the file can be parsed reliably and normalized into a consistent internal representation.
CSV Structure Checks
If
pandasis available, use it for CSV parsing.Otherwise, fall back to Python's built-in
csvmodule.Ensure the CSV contains exactly 4 or 5 logical columns.
Comment lines are allowed.
A header row may or may not be present.
If no header row exists, assume the implicit column order:
ip_prefix, alpha2code, region, city, postal code (deprecated)Refer to the example input file:
assets/example/01-user-input-rfc8805-feed.csv
CSV Cleansing and Normalization
Clean and normalize the CSV using Python logic equivalent to the following operations:
Select only the first five columns, dropping any columns beyond the fifth.
Write the output file with a UTF-8 BOM.
Comments
Remove comment rows where the first column begins with
#.This also removes a header row if it begins with
#.Create a map of comments using the 1-based line number as the key and the full original line as the value. Also store blank lines.
Store this map in a JSON file at:
./run/data/comments.jsonExample:
{ "4": "# It's OK for small city states to leave state ISO2 code unspecified" }
Notes
Both implementation paths (
pandasand built-incsv) must write output using theutf-8-sigencoding to ensure a UTF-8 BOM is present.
IP Prefix Analysis
Check that the
IPPrefixfield is present and non-empty for each entry.Check for duplicate
IPPrefixvalues across entries.If duplicates are found, stop the skill and report to the user with the message:
Duplicate IP prefix detected: {ip_prefix_value} appears on lines {line_numbers}If no duplicates are found, continue with the analysis.
Checks
Each subnet must parse cleanly as either an IPv4 or IPv6 network using the code snippets in the
references/folder.Subnets must be normalized and displayed in CIDR slash notation.
Single-host IPv4 subnets must be represented as
/32.Single-host IPv6 subnets must be represented as
/128.
ERROR
Report the following conditions as ERROR:
Invalid subnet syntax
Message ID:
1102
Non-public address space
Applies to subnets that are private, loopback, link-local, multicast, or otherwise non-public
In Python, detect non-public ranges using
is_privateand related address properties as shown in./references.
Message ID:
1103
SUGGESTION
Report the following conditions as SUGGESTION:
Overly large IPv6 subnets
Prefixes shorter than
/64Message ID:
3102
Overly large IPv4 subnets
Prefixes shorter than
/22Message ID:
3101
Geolocation Quality Check
Analyze the accuracy and consistency of geolocation data:
Country codes
Region codes
City names
Deprecated fields
This phase runs after structural checks pass.
Country Code Analysis
Use the locally available data table
ISO3166-1for checking.JSON array of countries and territories with ISO codes
Each object includes:
alpha_2: two-letter country codename: short country nameflag: flag emoji
This file represents the superset of valid
CountryCodevalues for an RFC 8805 CSV.
Check the entry's
CountryCode(RFC 8805 Section 2.1.1.2, columnalpha2code) against thealpha_2attribute.Sample code is available in the
references/directory.If a country is found in
assets/small-territories.json, mark the entry internally as a small territory. This flag is used in later checks and suggestions but is not stored in the output JSON (it is transient validation state).Note:
small-territories.jsoncontains some historic/disputed codes (AN,CS,XK) that are not present iniso3166-1.json. An entry using one of these as itsCountryCodewill fail the country code validation (ERROR) even though it matches as a small territory. The country code ERROR takes precedence — do not suppress it based on the small-territory flag.ERROR
Report the following conditions as ERROR:
Invalid country code
Condition:
CountryCodeis present but not found in thealpha_2setMessage ID:
1201
SUGGESTION
Report the following conditions as SUGGESTION:
Unspecified geolocation for subnet
Condition: All geographical fields (
CountryCode,RegionCode,City) are empty for a subnet.Action:
Set
DoNotGeolocate = truefor the entry.Set
CountryCodetoZZfor the entry.
Message ID:
3104
Region Code Analysis
Use the locally available data table
ISO3166-2for checking.JSON array of country subdivisions with ISO-assigned codes
Each object includes:
code: subdivision code prefixed with country code (e.g.,US-CA)name: short subdivision name
This file represents the superset of valid
RegionCodevalues for an RFC 8805 CSV.
If a
RegionCodevalue is provided (RFC 8805 Section 2.1.1.3):Check that the format matches
{COUNTRY}-{SUBDIVISION}(e.g.,US-CA,AU-NSW).Check the value against the
codeattribute (already prefixed with the country code).
Small-territory exception: If the entry is a small territory and the
RegionCodevalue equals the entry'sCountryCode(e.g.,SGas both country and region for Singapore), treat the region as acceptable — skip all region validation checks for this entry. Small territories are effectively city-states with no meaningful ISO 3166-2 administrative subdivisions.ERROR
Report the following conditions as ERROR:
Invalid region format
Condition:
RegionCodedoes not match{COUNTRY}-{SUBDIVISION}and the small-territory exception does not applyMessage ID:
1301
Unknown region code
Condition:
RegionCodevalue is not found in thecodeset and the small-territory exception does not applyMessage ID:
1302
Country–region mismatch
Condition: Country portion of
RegionCodedoes not matchCountryCodeMessage ID:
1303
City Name Analysis
City names are validated using heuristic checks only.
There is currently no authoritative dataset available for validating city names.
ERROR
Report the following conditions as ERROR:
Placeholder or non-meaningful values
Condition: Placeholder or non-meaningful values including but not limited to:
undefinedPlease selectnullN/ATBDunknown
Message ID:
1401
Truncated names, abbreviations, or airport codes
Condition: Truncated names, abbreviations, or airport codes that do not represent valid city names:
LAFrftsin01LHRSINMAA
Message ID:
1402
WARNING
Report the following conditions as WARNING:
Inconsistent casing or formatting
Condition: City names with inconsistent casing, spacing, or formatting that may reduce data quality, for example:
HongKongvsHong KongMixed casing or unexpected script usage
Message ID:
2401
Postal Code Check
RFC 8805 Section 2.1.1.5 explicitly deprecates postal or ZIP codes.
Postal codes can represent very small populations and are not considered privacy-safe for mapping IP address ranges, which are statistical in nature.
ERROR
Report the following conditions as ERROR:
Postal code present
Condition: A non-empty value is present in the postal/ZIP code field.
Message ID:
1501
Tuning & Recommendations
This phase applies opinionated recommendations beyond RFC 8805, learned from real-world geofeed deployments, that improve accuracy and usability.
SUGGESTION
Report the following conditions as SUGGESTION:
Region or city specified for small territory
Condition:
Entry is a small territory
RegionCodeis non-empty ORCityis non-empty.
Message IDs:
3301(for region),3402(for city)
Missing region code when city is specified
Condition:
Cityis non-emptyRegionCodeis emptyEntry is not a small territory
Message ID:
3303
Phase 4: Tuning Data Lookup
Objective
Lookup all the Entries using Fastah's rfc8805-row-place-search tool.
Execution Rules
Generate a new script only for payload generation (read the dataset and write one or more payload JSON files; do not call MCP from this script).
Server only accepts 1000 entries per request, so if there are more than 1000 entries, split into multiple requests.
The agent must read the generated payload files, construct the requests from them, and send those requests to the MCP server in batches of at most 1000 entries each.
On MCP failure: If the MCP server is unreachable, returns an error, or returns no results for any batch, log a warning and continue to Phase 5. Set
TunedEntry: {}for all affected entries. Do not block report generation. Notify the user clearly:Tuning data lookup unavailable; the report will show validation results only.Suggestions are advisory only — never auto-populate them.
Step 1: Build Lookup Payload with Deduplication
Load the dataset from: ./run/data/report-data.json
Read the
Entriesarray. Each entry will be used to build the MCP lookup payload.
Reduce server requests by deduplicating identical entries:
For each entry in
Entries, compute a content hash (hash ofCountryCode+RegionCode+City).Create a deduplication map:
{ contentHash -> { rowKey, payload, entryIndices: [] } }. rowKey is a UUID that will be sent to the MCP server for matching responses.If an entry's hash already exists, append its 0-based array index in
Entriesto that deduplication entry'sentryIndicesarray.If hash is new, generate a UUID (rowKey) and create a new deduplication entry.
Build request batches:
Extract unique deduplicated entries from the map, keeping them in deduplication order.
Build request batches of up to 1000 items each.
For each batch, keep an in-memory structure like
[{ rowKey, payload, entryIndices }, ...]to match responses back by rowKey.When writing the MCP payload file, include the
rowKeyfield with each payload object:
[
{"rowKey": "550e8400-e29b-41d4-a716-446655440000", "countryCode":"CA","regionCode":"CA-ON","cityName":"Toronto"},
{"rowKey": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "countryCode":"IN","regionCode":"IN-KA","cityName":"Bangalore"},
{"rowKey": "6ba7b811-9dad-11d1-80b4-00c04fd430c8", "countryCode":"IN","regionCode":"IN-KA"}
]When reading responses, match each response
rowKeyfield to the corresponding deduplication entry to retrieve all associatedentryIndices.
Rules:
Write payload to:
./run/data/mcp-server-payload.jsonExit the script after writing the payload.
Step 2: Invoke Fastah MCP Tool
An example
mcp.jsonstyle configuration of Fastah MCP server is as follows:
"fastah-ip-geofeed": {
"type": "http",
"url": "https://mcp.fastah.ai/mcp"
}Server:
https://mcp.fastah.ai/mcpTool and its Schema: before the first
tools/call, the agent MUST send atools/listrequest to read the input and output schema forrfc8805-row-place-search. Use the discovered schema as the authoritative source for field names, types, and constraints.The following is an illustrative example only; always defer to the schema returned by
tools/list:[ {"rowKey": "550e8400-...", "countryCode":"CA", ...}, {"rowKey": "690e9301-...", "countryCode":"ZZ", ...} ]Open
./run/data/mcp-server-payload.jsonand send all deduplicated entries with their rowKeys.If there are more than 1000 deduplicated entries after deduplication, split into multiple requests of 1000 entries each.
The server will respond with the same
rowKeyfield in each response for mapping back.Do NOT use local data.
Step 3: Attach Tuned Data to Entries
Generate a new script for attaching tuned data.
Load both
./run/data/report-data.jsonand the deduplication map (held in memory from Step 1, or re-derived from the payload file).For each response from the MCP server:
Extract the
rowKeyfrom the response.Look up the
entryIndicesarray associated with thatrowKeyfrom the deduplication map.For each index in
entryIndices, attach the best match toEntries[index].
Use the first (best) match from the response when available.
Create the field on each affected entry if it does not exist. Remap the MCP API response keys to Go struct field names:
"TunedEntry": {
"Name": "",
"CountryCode": "",
"RegionCode": "",
"PlaceType": "",
"H3Cells": [],
"BoundingBox": []
}The TunedEntry field is a single object (not an array). It holds the best match from the MCP server.
MCP response key → JSON key mapping:
| MCP API response key | JSON key |
|---|---|
placeName | Name |
countryCode | CountryCode |
stateCode | RegionCode |
placeType | PlaceType |
h3Cells | H3Cells |
boundingBox | BoundingBox |
Entries with no UUID match (i.e. the MCP server returned no response for their UUID) must receive an empty TunedEntry: {} object — never leave the field absent.
Write the dataset back to:
./run/data/report-data.jsonRules:
Maintain all existing validation flags.
Do NOT create additional intermediate files.
Phase 5: Generate Tuning Report
Generate a self-contained HTML report by rendering the template at ./scripts/templates/index.html with data from ./run/data/report-data.json and ./run/data/comments.json.
Write the completed report to ./run/report/geofeed-report.html. After generating, attempt to open it in the system's default browser (e.g., webbrowser.open()). If running in a headless environment, CI pipeline, or remote container where no browser is available, skip the browser step and instead present the file path to the user so they can open or download it.
The template uses Go html/template syntax ({{.Field}}, {{range}}, {{if eq}}, etc.). Write a Python script that reads the template, builds a rendering context from the JSON data files, and processes the template placeholders to produce final HTML. Do not modify the template file itself — all processing happens in the Python script at render time.
Step 1: Replace Metadata Placeholders
Replace each {{.Metadata.X}} placeholder in the template with the corresponding value from report-data.json. Since JSON keys match the template placeholder, the mapping is direct — {{.Metadata.InputFile}} maps to the InputFile JSON key, etc.
| Template placeholder | JSON key (report-data.json) |
|---|---|
{{.Metadata.InputFile}} | InputFile |
{{.Metadata.Timestamp}} | Timestamp |
{{.Metadata.TotalEntries}} | TotalEntries |
{{.Metadata.IpV4Entries}} | IpV4Entries |
{{.Metadata.IpV6Entries}} | IpV6Entries |
{{.Metadata.InvalidEntries}} | InvalidEntries |
{{.Metadata.Errors}} | Errors |
{{.Metadata.Warnings}} | Warnings |
{{.Metadata.Suggestions}} | Suggestions |
{{.Metadata.OK}} | OK |
{{.Metadata.CityLevelAccuracy}} | CityLevelAccuracy |
{{.Metadata.RegionLevelAccuracy}} | RegionLevelAccuracy |
{{.Metadata.CountryLevelAccuracy}} | CountryLevelAccuracy |
{{.Metadata.DoNotGeolocate}} | DoNotGeolocate (metadata) |
Note on {{.Metadata.Timestamp}}: This placeholder appears inside a JavaScript new Date(...) call. Replace it with the raw integer value (no HTML escaping needed for a numeric literal inside <script>). All other metadata values should be HTML-escaped since they appear inside HTML element text.
Step 2: Replace the Comment Map Placeholder
Locate this pattern in the template:
const commentMap = {{.Comments}};Replace {{.Comments}} with the serialized JSON object from ./run/data/comments.json. The JSON is embedded directly as a JavaScript object literal (not inside a string), so no extra escaping is needed:
comments_json = json.dumps(comments)
template = template.replace("{{.Comments}}", comments_json)Step 3: Expand the Entries Range Block
The template contains a {{range .Entries}}...{{end}} block inside <tbody id="entriesTableBody">. Process it as follows:
Extract the range block body using regex. Critical: The block contains nested
{{end}}tags (from{{if eq .Status ...}},{{if .Checked}}, and{{range .Messages}}). A naive non-greedy match like\{\{range \.Entries\}\}(.*?)\{\{end\}\}will match the first inner{{end}}, truncating the block. Instead, anchor the outer{{end}}to the</tbody>that follows it:m = re.search( r'\{\{range \.Entries\}\}(.*?)\{\{end\}\}\s*</tbody>', template, re.DOTALL, ) entry_body = m.group(1) # template text for one entry iterationThis ensures you capture the full block body including all three
<tr>rows and the nested{{range .Messages}}...{{end}}.Iterate over each entry in
report-data.json'sEntriesarray.Expand the block body for each entry using the processing order below.
Replace the entire match (from
{{range .Entries}}through</tbody>) with the concatenated expanded HTML followed by</tbody>.
Processing order for each entry (innermost constructs first to avoid {{end}} confusion):
Evaluate
{{if eq .Status ...}}...{{end}}conditionals (status badge class and icon).Evaluate
{{if .Checked}}...{{end}}conditional (message checkbox).Expand
{{range .Messages}}...{{end}}inner range.Replace simple
{{.Field}}placeholders.
Entry Field Mapping
Within the range block body, replace these placeholders for each entry. Since JSON keys match the template placeholder, the template placeholder {{.X}} maps directly to JSON key X:
| Template placeholder | JSON key (Entries[]) | Notes |
|---|---|---|
{{.Line}} | Line | Direct integer value |
{{.IPPrefix}} | IPPrefix | HTML-escaped |
{{.CountryCode}} | CountryCode | HTML-escaped |
{{.RegionCode}} | RegionCode | HTML-escaped |
{{.City}} | City | HTML-escaped |
{{.Status}} | Status | HTML-escaped |
{{.HasError}} | HasError | Lowercase string: "true" or "false" |
{{.HasWarning}} | HasWarning | Lowercase string: "true" or "false" |
{{.HasSuggestion}} | HasSuggestion | Lowercase string: "true" or "false" |
{{.GeocodingHint}} | GeocodingHint | Empty string "" |
{{.DoNotGeolocate}} | DoNotGeolocate | "true" or "false" |
{{.Tunable}} | Tunable | "true" or "false" |
{{.TunedEntry.CountryCode}} | TunedEntry.CountryCode | "" if TunedEntry is empty {} |
{{.TunedEntry.RegionCode}} | TunedEntry.RegionCode | "" if TunedEntry is empty {} |
{{.TunedEntry.Name}} | TunedEntry.Name | "" if TunedEntry is empty {} |
{{.TunedEntry.H3Cells}} | TunedEntry.H3Cells | Bracket-wrapped space-separated; "[]" if empty (see format below) |
{{.TunedEntry.BoundingBox}} | TunedEntry.BoundingBox | Bracket-wrapped space-separated; "[]" if empty (see format below) |
data-h3-cells and data-bounding-box format: These are NOT JSON arrays. They are bracket-wrapped, space-separated values. Do not use JSON serialization (no quotes around string elements, no commas between numbers). Examples:
[836752fffffffff 836755fffffffff]— correct["836752fffffffff","836755fffffffff"]— WRONG, quotes will break parsing[-71.70 10.73 -71.52 10.55]— correct[]— correct for empty
Evaluating Status Conditionals
Process these BEFORE replacing simple {{.Field}} placeholders — otherwise the {{end}} markers get consumed and the regex won't match.
The template uses {{if eq .Status "..."}} conditionals for the status badge CSS class and icon. Evaluate these by checking the entry's status value and keeping only the matching branch text.
The status badge line contains two {{if eq .Status ...}}...{{end}} blocks on a single line — one for the CSS class, one for the icon. Use re.sub with a callback to resolve all occurrences:
STATUS_CSS = {"ERROR": "error", "WARNING": "warning", "SUGGESTION": "suggestion", "OK": "ok"}
STATUS_ICON = {
"ERROR": "bi-x-circle-fill",
"WARNING": "bi-exclamation-triangle-fill",
"SUGGESTION": "bi-lightbulb-fill",
"OK": "bi-check-circle-fill",
}
def resolve_status_if(match_obj, status):
"""Pick the branch matching `status` from a {{if eq .Status ...}}...{{end}} block."""
block = match_obj.group(0)
# Try each branch: {{if eq .Status "X"}}val{{else if ...}}val{{else}}val{{end}}
for st, val in [("ERROR",), ("WARNING",), ("SUGGESTION",)]:
# not needed to parse generically — just map from the known patterns
...A simpler approach: since there are exactly two known patterns, replace them as literal strings:
css_class = STATUS_CSS.get(status, "ok")
icon_class = STATUS_ICON.get(status, "bi-check-circle-fill")
body = body.replace(
'{{if eq .Status "ERROR"}}error{{else if eq .Status "WARNING"}}warning{{else if eq .Status "SUGGESTION"}}suggestion{{else}}ok{{end}}',
css_class,
)
body = body.replace(
'{{if eq .Status "ERROR"}}bi-x-circle-fill{{else if eq .Status "WARNING"}}bi-exclamation-triangle-fill{{else if eq .Status "SUGGESTION"}}bi-lightbulb-fill{{else}}bi-check-circle-fill{{end}}',
icon_class,
)This avoids regex entirely and is safe because these exact strings appear verbatim in the template.
Step 4: Expand the Nested Messages Range
The {{range .Messages}}...{{end}} block contains a nested {{if .Checked}} checked{{else}} disabled{{end}} conditional, so its inner {{end}} would cause a simple non-greedy regex to match too early. Anchor the regex to </td> (the tag immediately after the messages range closing {{end}}) to capture the full block body:
msg_match = re.search(
r'\{\{range \.Messages\}\}(.*?)\{\{end\}\}\s*(?=</td>)',
body, re.DOTALL
)The lookahead (?=</td>) ensures the regex skips past the checkbox conditional's {{end}} (which is followed by >, not </td>) and matches only the range-closing {{end}} (which is followed by whitespace then </td>).
For each message in the entry's Messages array, clone the captured block body and expand it:
Resolve the checkbox conditional per message (must happen before simple placeholder replacement to remove the nested
{{end}}):if msg.get("Checked"): msg_body = msg_body.replace( '{{if .Checked}} checked{{else}} disabled{{end}}', ' checked' ) else: msg_body = msg_body.replace( '{{if .Checked}} checked{{else}} disabled{{end}}', ' disabled' )Replace message field placeholders:
Template placeholder Source Notes {{.ID}}Messages[i].IDDirect string value from JSON {{.Text}}Messages[i].TextHTML-escaped Concatenate all expanded message blocks and replace the original
{{range .Messages}}...{{end}}match (msg_match.group(0)) with the result:body = body[:msg_match.start()] + "".join(expanded_msgs) + body[msg_match.end():]
If Messages is empty, replace the entire matched region with an empty string (no message divs — only the issues header remains).
Output Guarantees
The report must be readable in any modern browser without extra network dependencies beyond the CDN links already in the template (
leaflet,h3-js,bootstrap-icons, Raleway font).All values embedded in HTML must be HTML-escaped (
<,>,&,") to prevent rendering issues.commentMapis embedded as a direct JavaScript object literal (not inside a string), so no JS string escaping is needed — just emit valid JSON.All values must be derived only from analysis output, not recomputed heuristically.
Phase 6: Final Review
Perform a final verification pass using concrete, checkable assertions before presenting results to the user.
Check 1 — Entry count integrity
Count non-comment, non-blank data rows in the original input CSV.
Assert:
len(entries) in report-data.json == data_row_countOn failure:
Row count mismatch: input has {N} data rows but report contains {M} entries.
Check 2 — Summary counter integrity
These counters use mutual exclusion based on the boolean flags, which mirrors the highest-severity
Statusfield. An entry with bothHasError: trueandHasWarning: trueis counted only inErrors, never inWarnings. This is equivalent to counting by the entry'sStatusfield.Assert all of the following; correct any that fail before generating the report:
Errors == sum(1 for e in Entries if e['HasError'])Warnings == sum(1 for e in Entries if e['HasWarning'] and not e['HasError'])Suggestions == sum(1 for e in Entries if e['HasSuggestion'] and not e['HasError'] and not e['HasWarning'])OK == sum(1 for e in Entries if not e['HasError'] and not e['HasWarning'] and not e['HasSuggestion'])Errors + Warnings + Suggestions + OK == TotalEntries - InvalidEntries
Check 3 — Accuracy bucket integrity
Assert:
CityLevelAccuracy + RegionLevelAccuracy + CountryLevelAccuracy + DoNotGeolocate == TotalEntries - InvalidEntriesNote: The accuracy buckets defined in Phase 3 say "Do not count entries with
HasError: true", but the Check 3 formula above usesTotalEntries - InvalidEntries(which still includes ERROR entries). This means ERROR entries (those that parsed as valid IPs but failed validation) are counted in accuracy buckets by their geo-field presence. OnlyInvalidEntries(unparsable IP prefixes) are excluded. Follow the Check 3 formula as the authoritative rule.On failure, trace and fix the bucketing logic before proceeding.
Check 4 — No duplicate line numbers
Assert: all
Linevalues inEntriesare unique.On failure, report the duplicated line numbers to the user.
Check 5 — TunedEntry completeness
Assert: every object in
Entrieshas aTunedEntrykey (even if its value is{}).On failure, add
"TunedEntry": {}to any entry missing the key, then re-savereport-data.json.
Check 6 — Report file is present and non-empty
Confirm
./run/report/geofeed-report.htmlwas written and has a file size greater than zero bytes.On failure, regenerate the report before presenting to the user.
Files
- ATTRIBUTION.mdAdded by AutoGineer996 B
- LICENSEAdded by AutoGineer10.0 KiB
- SKILL.md47.6 KiB
- assets/example/01-user-input-rfc8805-feed.csv214 B
- assets/iso3166-1.json22.0 KiB
- assets/iso3166-2.json313.2 KiB
- assets/small-territories.json1.0 KiB
- references/rfc8805.txt29.9 KiB
- references/snippets-python3.md3.4 KiB
- scripts/templates/index.html98.2 KiB
scripts/templates/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Geofeed Tuner Report</title>
<link href="https://fonts.googleapis.com/css2?family=Raleway:wght@300;400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css">
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
<script src="https://unpkg.com/[email protected]/dist/h3-js.umd.js"></script>
<style>
body {
background-color: #adadad;
padding: 20px;
font-family: 'Raleway', sans-serif;
}
i[class*="bi"] {
font-weight: 700;
text-shadow: 0 0 0.3px currentColor;
}
.report-container {
background-color: white;
border-radius: 15px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 30px;
margin: 0 auto 20px auto;
max-width: 1400px;
}
.metadata {
background-color: #f0f0f0;
padding: 20px;
border-radius: 5px;
margin-bottom: 30px;
}
.metadata h3 {
margin-bottom: 15px;
color: #333333;
}
.metadata-item {
display: inline-block;
margin-right: 40px;
margin-bottom: 10px;
}
.metadata-label {
font-weight: bold;
color: #696969;
}
.metadata-value {
color: #333333;
}
.status-badge {
padding: 3px 10px;
border-radius: 4px;
font-weight: bold;
font-size: 0.75em;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
white-space: nowrap;
width: 150px;
}
.status-error {
background-color: #ff707f;
color: #ffffff;
border: 1px solid #ff707f;
}
.status-warning {
background-color: #ffdc73;
color: #000000;
border: 1px solid #ffdc73;
}
.status-suggestion {
background-color: #1877ec;
color: #ffffff;
border: 1px solid #1877ec;
}
.status-ok {
background-color: #28a745;
color: #ffffff;
border: 1px solid #28a745;
}
/* Checkbox styling */
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: #4a4a4a;
display: block;
margin: 0 auto;
}
input[type="checkbox"]:hover {
transform: scale(1.1);
}
/* Disabled checkbox with X effect */
input[type="checkbox"]:disabled {
cursor: not-allowed;
opacity: 0.6;
position: relative;
}
input[type="checkbox"]:disabled:hover {
transform: none;
}
/* Create X overlay on disabled checkbox */
.message-line input[type="checkbox"]:disabled {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
width: 16px;
height: 16px;
border: 2px solid #dc3545;
border-radius: 3px;
background-color: #dc3545;
position: relative;
cursor: not-allowed;
}
.message-line input[type="checkbox"]:disabled::before,
.message-line input[type="checkbox"]:disabled::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 10px;
height: 2px;
background-color: #ffffff;
}
.message-line input[type="checkbox"]:disabled::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.message-line input[type="checkbox"]:disabled::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
#selectAll {
width: 17px;
height: 17px;
}
.entries-table td:first-child,
.entries-table th:first-child {
text-align: center;
vertical-align: middle;
}
.entries-table {
width: 100%;
border-collapse: collapse;
border-radius: 8px;
overflow: hidden;
table-layout: fixed;
}
.entries-table thead {
background-color: #4a4a4a;
color: white;
}
.entries-table th {
padding: 12px;
text-align: left;
border: 1px solid #d3d3d3;
font-weight: 600;
background-color: #6b6b6b;
color: #ffffff;
}
.entries-table td {
padding: 12px;
border: 1px solid #d3d3d3;
vertical-align: top;
word-wrap: break-word;
overflow-wrap: break-word;
}
.entries-table tbody tr:not(.expand-details-row) td:nth-child(3) {
text-align: center;
vertical-align: top;
}
.entries-table tbody tr:nth-child(odd) {
background-color: #f9f9f9;
}
.entries-table tbody tr:hover {
background-color: #f0f0f0;
}
.messages-list {
list-style: none;
padding: 0;
margin: 0;
}
.messages-list li {
padding: 1px 0;
border-left: 3px solid #b0b0b0;
padding-left: 10px;
color: #696969;
font-size: 0.95em;
}
h1 {
color: #ffffff;
margin-bottom: 10px;
margin-top: -30px;
margin-left: -30px;
margin-right: -30px;
border-bottom: none;
padding-bottom: 10px;
font-size: 3em;
font-weight: 700;
font-family: 'Raleway', sans-serif;
background: linear-gradient(135deg, #2a2a2a 0%, #4a4a4a 100%);
padding: 30px;
border-radius: 15px 15px 0 0;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.metrics-table {
width: 100%;
border-collapse: collapse;
margin: 0 auto 20px auto;
border-radius: 8px;
overflow: hidden;
}
.metrics-table td {
padding: 10px 15px;
border: 1px solid #d3d3d3;
}
.metrics-table tr:nth-child(even) {
background-color: #f9f9f9;
}
.metrics-label {
font-weight: bold;
width: 30%;
background-color: #f0f0f0;
}
.metrics-value {
width: 70%;
color: #333333;
}
.metrics-table.feed-metadata .metrics-label {
width: 20%;
}
.metrics-table.feed-metadata .metrics-value {
width: 80%;
}
.accuracy-summary-table td {
width: auto !important;
}
.accuracy-summary-table .metrics-label {
width: 15% !important;
}
.accuracy-summary-table .metrics-value {
width: 35% !important;
}
#filterCountry, #filterRegion, #filterCity, #filterStatus, #filterIpPrefix {
box-sizing: border-box;
font-family: 'Raleway', sans-serif;
}
#filterCountry::placeholder, #filterRegion::placeholder, #filterCity::placeholder, #filterIpPrefix::placeholder {
color: #aaa;
}
#resetFilters:hover, #toggleFilters:hover, #toggleExpandAll:hover, #downloadCSV:hover {
background-color: #6a6a6a;
border-color: #aaa;
}
.expand-details-row {
display: none;
}
.expand-details-row.show {
display: table-row;
}
.expand-details-row td {
border-left: none;
border-right: none;
}
.expand-details-row td:first-child {
border-left: 1px solid #d3d3d3;
padding: 0;
}
.expand-details-row td:nth-child(2) {
padding: 0;
}
.expand-details-row td:last-child {
border-right: 1px solid #d3d3d3;
}
.previous-values-row td {
background-color: #f0f0f0;
font-style: italic;
color: #555;
padding: 8px 12px;
border-top: 1px solid #d3d3d3;
border-bottom: 1px solid #d3d3d3;
}
.previous-values-row td:first-child {
border-left: 1px solid #d3d3d3;
}
.previous-values-row td:last-child {
border-right: 1px solid #d3d3d3;
}
.issues-row td {
border-top: 1px solid #d3d3d3;
border-bottom: 1px solid #d3d3d3;
}
.expandable-row {
cursor: pointer;
}
.expandable-row:focus {
outline: 2px solid #4a9eff;
outline-offset: -2px;
}
.tune-all-btn {
display: none;
padding: 3px 8px;
border: 1px solid #888;
border-radius: 3px;
background-color: #5a5a5a;
color: #ffffff;
cursor: pointer;
font-weight: 600;
font-size: 0.8em;
}
body.tuning-mode .tune-all-btn {
display: inline;
}
.issues-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.message-line {
display: flex;
justify-content: space-between;
align-items: center;
margin: 5px 0;
}
.message-line input[type="checkbox"] {
display: none;
}
body.tuning-mode .message-line input[type="checkbox"] {
display: inline-block;
}
.message-line span {
flex: 1;
font-size: 0.98em;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
}
.modal-content {
background-color: #ffffff;
margin: 50px auto;
padding: 0;
border: none;
width: 90%;
max-width: 1200px;
border-radius: 15px;
max-height: 90vh;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
}
.modal-content h2 {
background-color: #4a4a4a;
color: #ffffff;
padding: 20px 25px;
margin: 0;
font-size: 1.3em;
font-weight: bold;
font-family: 'Raleway', sans-serif;
border-radius: 15px 15px 0 0;
position: relative;
}
.close {
color: #ffffff;
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
font-size: 32px;
font-weight: bold;
cursor: pointer;
line-height: 1;
transition: color 0.2s;
}
.close:hover,
.close:focus {
color: #ddd;
}
.matches-container {
margin-top: 0;
padding: 25px;
display: flex;
flex-wrap: nowrap;
gap: 15px;
justify-content: center;
overflow-x: auto;
background-color: #f5f5f5;
flex: 1;
overflow-y: auto;
}
.match-item {
flex: 1 1 0;
max-width: 350px;
min-width: 280px;
border: 2px solid #c0c0c0;
border-radius: 8px;
padding: 0;
background-color: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
cursor: pointer;
transition: all 0.2s;
overflow: hidden;
}
.match-item:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
border-color: #4a9eff;
transform: translateY(-2px);
}
.match-item:active {
transform: translateY(0);
}
.match-item.selected {
border-color: #4a9eff;
border-width: 3px;
box-shadow: 0 0 15px rgba(74, 158, 255, 0.6);
}
.match-map {
height: 180px;
width: 100%;
margin: 0;
border: none;
border-radius: 0;
}
.match-info-container {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
background-color: #f0f0f0;
}
.match-header {
font-size: 22px;
font-weight: bold;
color: #333;
text-align: left;
font-family: 'Raleway', sans-serif;
line-height: 1.2;
}
.location-details {
font-size: 15px;
color: #666;
display: inline;
}
.location-details span {
margin-right: 15px;
}
.default-values {
display: none;
padding: 5px 0;
font-size: 13px;
margin-bottom: 10px;
}
.default-values.show {
display: block;
}
.mode-toggle-container {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.72em;
}
.mode-toggle {
position: relative;
width: 240px;
height: 32px;
background-color: #888;
border-radius: 16px;
cursor: pointer;
transition: background-color 0.3s;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
}
.mode-toggle:focus {
outline: 2px solid #4a9eff;
outline-offset: 2px;
}
.mode-toggle-text {
font-size: 0.85em;
font-weight: 600;
color: #cccccc;
z-index: 1;
transition: color 0.3s;
flex: 1;
text-align: center;
}
.mode-toggle-text.active {
color: #333333;
}
.mode-toggle-slider {
position: absolute;
top: 3px;
left: 3px;
width: 116px;
height: 26px;
background-color: #ffffff;
border-radius: 13px;
transition: transform 0.3s;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.mode-toggle.tuning .mode-toggle-slider {
transform: translateX(118px);
}
.mode-toggle.h3mode .mode-toggle-slider {
transform: translateX(96px);
}
.mode-toggle-label {
font-weight: 600;
color: #ffffff;
font-size: 0.9em;
}
/* Keyboard accessibility focus styles */
button:focus {
outline: 2px solid #4a9eff;
outline-offset: 2px;
}
input[type="text"]:focus,
select:focus {
outline: 2px solid #4a9eff;
outline-offset: 1px;
}
input[type="checkbox"]:focus {
outline: 2px solid #4a9eff;
outline-offset: 2px;
}
/* Pagination styles */
.pagination-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
padding: 15px 20px;
background-color: #f5f5f5;
border-radius: 8px;
flex-wrap: wrap;
gap: 15px;
}
.pagination-info {
color: #333333;
font-size: 0.95em;
}
.pagination-controls {
display: flex;
gap: 5px;
align-items: center;
}
.pagination-btn {
padding: 6px 12px;
border: 1px solid #888;
border-radius: 4px;
background-color: #5a5a5a;
color: #ffffff;
cursor: pointer;
font-weight: 600;
font-size: 0.9em;
transition: background-color 0.2s;
}
.pagination-btn:hover:not(:disabled) {
background-color: #4a4a4a;
}
.pagination-btn:disabled {
background-color: #888;
cursor: not-allowed;
opacity: 0.5;
}
.pagination-btn.active {
background-color: #1877ec;
border-color: #1877ec;
}
.page-size-selector {
display: flex;
align-items: center;
gap: 8px;
}
.page-size-selector select {
padding: 5px 10px;
border: 1px solid #888;
border-radius: 4px;
background-color: #5a5a5a;
color: #ffffff;
font-size: 0.9em;
cursor: pointer;
}
/* Helper classes for common inline styles */
.table-header {
background-color: #6b6b6b;
padding: 12px 15px;
font-weight: bold;
color: #ffffff;
font-size: 1.1em;
font-family: 'Raleway', sans-serif;
}
.icon-spacer {
margin-right: 8px;
}
.flex-container {
display: flex;
justify-content: space-between;
align-items: center;
}
.action-button {
padding: 5px 11px;
border: 1px solid #888;
border-radius: 3px;
background-color: #5a5a5a;
color: #ffffff;
cursor: pointer;
font-weight: 600;
font-size: 0.9em;
}
.action-button:hover {
background-color: #6a6a6a;
}
.subtitle-span {
font-size: 0.4em;
font-weight: 700;
display: block;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="report-container">
<h1>Geofeed Tuner Report<br><span style="font-size: 0.4em; font-weight: 700; display: block; margin-top: 10px;">RFC 8805 Validation & Recommendations</span></h1>
<table class="metrics-table feed-metadata" style="margin-top: 30px; margin-bottom: 30px;">
<tr style="background-color: #6b6b6b;">
<td colspan="2" style="padding: 12px 15px; font-weight: bold; color: #ffffff; font-size: 1.1em; font-family: 'Raleway', sans-serif;">Feed Metadata</td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-filetype-csv" style="margin-right: 8px;"></i>Input file</td>
<td class="metrics-value"><span id="inputFileMetrics">{{.Metadata.InputFile}}</span></td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-calendar-event-fill" style="margin-right: 8px;"></i>Tuning timestamp</td>
<td class="metrics-value"><script>document.write(new Intl.DateTimeFormat("en", {dateStyle: "full", timeStyle: "long",}).format(new Date({{.Metadata.Timestamp}})))</script></td>
</tr>
</table>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<table class="metrics-table">
<tr style="background-color: #6b6b6b;">
<td colspan="2" style="padding: 12px 15px; font-weight: bold; color: #ffffff; font-size: 1.1em; font-family: 'Raleway', sans-serif;">Entries</td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-list-ul" style="margin-right: 8px;"></i>Total entries</td>
<td class="metrics-value"><span id="totalEntriesMetrics">{{.Metadata.TotalEntries}}</span></td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-diagram-3-fill" style="margin-right: 8px;"></i>IPv4 entries</td>
<td class="metrics-value"><span id="ipv4EntriesMetrics">{{.Metadata.IpV4Entries}}</span></td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-diagram-3-fill" style="margin-right: 8px;"></i>IPv6 entries</td>
<td class="metrics-value"><span id="ipv6EntriesMetrics">{{.Metadata.IpV6Entries}}</span></td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-exclamation-circle-fill" style="margin-right: 8px;"></i>Invalid entries</td>
<td class="metrics-value"><span id="invalidEntriesMetrics">{{.Metadata.InvalidEntries}}</span></td>
</tr>
</table>
<table class="metrics-table">
<tr style="background-color: #6b6b6b;">
<td colspan="2" style="padding: 12px 15px; font-weight: bold; color: #ffffff; font-size: 1.1em; font-family: 'Raleway', sans-serif;">Analysis Summary</td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-x-circle-fill" style="margin-right: 8px;"></i>Errors</td>
<td class="metrics-value"><span id="errorCountMetrics">{{.Metadata.Errors}}</span></td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-exclamation-triangle-fill" style="margin-right: 8px;"></i>Warnings</td>
<td class="metrics-value"><span id="warningCountMetrics">{{.Metadata.Warnings}}</span></td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-lightbulb-fill" style="margin-right: 8px;"></i>Suggestions</td>
<td class="metrics-value"><span id="suggestionsMetrics">{{.Metadata.Suggestions}}</span></td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-check-circle-fill" style="margin-right: 8px;"></i>OK </td>
<td class="metrics-value"><span id="okCountMetrics">{{.Metadata.OK}}</span></td>
</tr>
</table>
</div>
<div style="margin-bottom: 40px;">
<table class="metrics-table accuracy-summary-table" style="margin-top: 0; margin-bottom: 0; table-layout: fixed; width: 100%;">
<colgroup>
<col style="width: 20%;">
<col style="width: 30%;">
<col style="width: 20%;">
<col style="width: 30%;">
</colgroup>
<tr style="background-color: #6b6b6b;">
<td colspan="4" style="padding: 12px 15px; font-weight: bold; color: #ffffff; font-size: 1.1em; font-family: 'Raleway', sans-serif;">Accuracy Summary</td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-building-fill" style="margin-right: 8px;"></i>City-level accuracy</td>
<td class="metrics-value"><span id="cityAccuracy">{{.Metadata.CityLevelAccuracy}}</span></td>
<td class="metrics-label"><i class="bi bi-map-fill" style="margin-right: 8px;"></i>Region-level accuracy</td>
<td class="metrics-value"><span id="regionAccuracy">{{.Metadata.RegionLevelAccuracy}}</span></td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-globe-americas" style="margin-right: 8px;"></i>Country-level accuracy</td>
<td class="metrics-value"><span id="countryAccuracy">{{.Metadata.CountryLevelAccuracy}}</span></td>
<td class="metrics-label"><i class="bi bi-ban-fill" style="margin-right: 8px;"></i>Do-not-geolocate entries</td>
<td class="metrics-value"><span id="doNotGeolocate">{{.Metadata.DoNotGeolocate}}</span></td>
</tr>
</table>
</div>
<div style="overflow-x: auto;">
<table class="entries-table">
<colgroup>
<col style="width: 3%;">
<col style="width: 5%;">
<col style="width: 20%;">
<col style="width: 24%;">
<col style="width: 10%;">
<col style="width: 19%;">
<col style="width: 19%;">
</colgroup>
<thead>
<tr style="background-color: #4a4a4a;">
<th colspan="7" style="padding: 12px; font-weight: bold; color: #ffffff; font-size: 1.3em; font-family: 'Raleway', sans-serif;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span><i class="bi bi-list-ul" style="margin-right: 10px;"></i>Entries</span>
<div class="mode-toggle-container">
<div class="mode-toggle" id="modeToggle" onclick="toggleMode()" tabindex="0" onkeydown="if(event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleMode(); }">
<span class="mode-toggle-text active" id="viewingText">Viewing Mode</span>
<div class="mode-toggle-slider"></div>
<span class="mode-toggle-text" id="tuningText">Tuning Mode</span>
</div>
</div>
</div>
</th>
</tr>
<tr>
<th colspan="7" style="background-color: #6b6b6b; padding: 8px;">
<div style="display: flex; justify-content: space-between; gap: 5px;">
<div style="display: flex; gap: 5px;">
<button id="toggleFilters" style="padding: 5px 11px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; cursor: pointer; font-weight: 600; font-size: 0.9em; width: 130px;">
<i class="bi bi-funnel-fill" style="margin-right: 5px;"></i>Show Filters
</button>
<button id="toggleExpandAll" style="padding: 5px 11px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; cursor: pointer; font-weight: 600; font-size: 0.9em; width: 145px;">
<i class="bi bi-arrows-expand" style="margin-right: 5px;"></i>Expand Rows
</button>
<button id="tuneAll" class="tune-all-btn" style="padding: 5px 11px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; cursor: pointer; font-weight: 600; font-size: 0.9em;">
<i class="bi bi-wrench" style="margin-right: 5px;"></i>Tune All
</button>
</div>
<button id="downloadCSV" style="padding: 5px 11px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; cursor: pointer; font-weight: 600; font-size: 0.9em;">
<i class="bi bi-filetype-csv" style="margin-right: 5px;"></i>Download
</button>
</div>
</th>
</tr>
<tr>
<th><input type="checkbox" id="selectAll" checked></th>
<th>Line</th>
<th>Status</th>
<th>IP Prefix</th>
<th>Country</th>
<th>Region</th>
<th>City</th>
</tr>
<tr id="filterRow" style="background-color: #6b6b6b; display: none;">
<th colspan="2" style="padding: 8px;">
<button id="resetFilters" style="padding: 5px 11px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; cursor: pointer; font-weight: 600; font-size: 0.9em; width: 100%;">Reset</button>
</th>
<th style="padding: 8px;">
<select id="filterStatus" style="padding: 5px 9px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; font-size: 0.9em; width: 100%;">
<option value="">All</option>
<option value="ERROR">ERROR</option>
<option value="WARNING">WARNING</option>
<option value="SUGGESTION">SUGGESTION</option>
<option value="OK">OK</option>
</select>
</th>
<th style="padding: 8px;">
<input type="text" id="filterIpPrefix" placeholder="IP Prefix" style="padding: 5px 9px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; font-size: 0.9em; width: 100%; box-sizing: border-box;">
</th>
<th style="padding: 8px;">
<input type="text" id="filterCountry" placeholder="Country" style="padding: 5px 9px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; font-size: 0.9em; width: 100%; box-sizing: border-box;">
</th>
<th style="padding: 8px;">
<input type="text" id="filterRegion" placeholder="Region" style="padding: 5px 9px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; font-size: 0.9em; width: 100%; box-sizing: border-box;">
</th>
<th style="padding: 8px;">
<input type="text" id="filterCity" placeholder="City" style="padding: 5px 9px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; font-size: 0.9em; width: 100%; box-sizing: border-box;">
</th>
</tr>
</thead>
<tbody id="entriesTableBody">
{{range .Entries}}
<tr
id="csv-r-{{.Line}}"
class="expandable-row"
data-geocoding-hint="{{.GeocodingHint}}"
data-do-not-geolocate="{{.DoNotGeolocate}}"
data-has-warning="{{.HasWarning}}"
data-has-error="{{.HasError}}"
data-has-suggestion="{{.HasSuggestion}}"
data-tunable="{{.Tunable}}"
data-tuned-country="{{.TunedEntry.CountryCode}}"
data-tuned-region="{{.TunedEntry.RegionCode}}"
data-tuned-city="{{.TunedEntry.Name}}"
data-h3-cells="{{.TunedEntry.H3Cells}}"
data-bounding-box="{{.TunedEntry.BoundingBox}}">
<td><input type="checkbox" class="row-checkbox" checked></td>
<td>{{.Line}}</td>
<td><span class="status-badge status-{{if eq .Status "ERROR"}}error{{else if eq .Status "WARNING"}}warning{{else if eq .Status "SUGGESTION"}}suggestion{{else}}ok{{end}}"><i class="bi {{if eq .Status "ERROR"}}bi-x-circle-fill{{else if eq .Status "WARNING"}}bi-exclamation-triangle-fill{{else if eq .Status "SUGGESTION"}}bi-lightbulb-fill{{else}}bi-check-circle-fill{{end}}"></i>{{.Status}}</span></td>
<td><strong>{{.IPPrefix}}</strong></td>
<td>{{.CountryCode}}</td>
<td>{{.RegionCode}}</td>
<td>{{.City}}</td>
</tr>
<tr class="expand-details-row previous-values-row">
<td></td>
<td></td>
<td></td>
<td><strong>Previous values:</strong></td>
<td class="previous-value"><span class="default-country">{{.CountryCode}}</span></td>
<td class="previous-value"><span class="default-region">{{.RegionCode}}</span></td>
<td class="previous-value"><span class="default-city">{{.City}}</span></td>
</tr>
<tr class="expand-details-row issues-row">
<td></td>
<td></td>
<td colspan="5">
<div class="issues-header">
<strong>Issues:</strong>
{{if .Tunable}}
<button class="tune-all-btn" onclick="handleTuneButtonClick(this)">Tune</button>
{{end}}
</div>
{{range .Messages}}
<div class="message-line" data-id="{{.ID}}">
<span>{{.Text}}</span>
<input type="checkbox"{{if .Checked}} checked{{else}} disabled{{end}}>
</div>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<!-- Pagination Controls -->
<div class="pagination-container">
<div class="pagination-info">
<span id="paginationInfo">Showing 0-0 of 0 rows</span>
</div>
<div class="pagination-controls">
<button class="pagination-btn" id="firstPage" title="First Page">
<i class="bi bi-chevron-bar-left"></i>
</button>
<button class="pagination-btn" id="prevPage" title="Previous Page">
<i class="bi bi-chevron-left"></i>
</button>
<div id="pageNumbers" style="display: flex; gap: 5px;"></div>
<button class="pagination-btn" id="nextPage" title="Next Page">
<i class="bi bi-chevron-right"></i>
</button>
<button class="pagination-btn" id="lastPage" title="Last Page">
<i class="bi bi-chevron-bar-right"></i>
</button>
</div>
<div class="page-size-selector">
<label for="pageSize" style="color: #333333; font-size: 0.9em;">Rows per page:</label>
<select id="pageSize">
<option value="100" selected>100</option>
<option value="all">All</option>
</select>
</div>
</div>
</div>
<div style="margin-top: 30px;">
<table class="metrics-table" style="margin-bottom: 0; width: 100%; border-bottom-left-radius: 0; border-bottom-right-radius: 0;">
<tr style="background-color: #6b6b6b;">
<td style="padding: 12px 15px; font-weight: bold; color: #ffffff; font-size: 1.1em; font-family: 'Raleway', sans-serif;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span><i class="bi bi-map-fill" style="margin-right: 10px;"></i>Geographic Coverage Map</span>
<div class="mode-toggle-container" style="font-size: 1em;">
<div class="mode-toggle" id="mapModeToggle" onclick="switchMapMode()" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();switchMapMode();}" style="width: 170px;">
<span class="mode-toggle-text active" id="mapModeBboxText">BBox</span>
<div class="mode-toggle-slider" style="width: 81px;"></div>
<span class="mode-toggle-text" id="mapModeH3Text">H3 Cells</span>
</div>
</div>
</div>
</td>
</tr>
</table>
<div id="summaryMap" style="height: 480px; border: 1px solid #bbb; border-top: none; border-radius: 0 0 4px 4px;"></div>
</div>
</div>
<div id="locationModal" class="modal" tabindex="-1">
<div class="modal-content" tabindex="0">
<h2>
<i class="bi bi-geo-alt" style="margin-right: 10px;"></i>Location Matches
<span class="close" tabindex="0">×</span>
</h2>
<div id="matchesList" class="matches-container"></div>
</div>
</div>
<script>
/**
* Table cell indices for accessing column data
* @type {Object<string, number>}
*/
// Constants
const CELL_INDEX = {
CHECKBOX: 0,
LINE: 1,
STATUS: 2,
IP_PREFIX: 3,
COUNTRY: 4,
REGION: 5,
CITY: 6,
MESSAGES: 7
};
/**
* Timing delays for UI interactions (in milliseconds)
* @type {Object<string, number>}
*/
const TIMING = {
MAP_INIT_DELAY: 100,
MODAL_FOCUS_DELAY: 150,
MAP_RESIZE_DELAY: 200
};
/**
* Global state variables for modal and map management
* @type {Object}
*/
// Global state
/** @type {Object<string, L.Map>} Maps indexed by mapId */
let maps = {};
/** @type {HTMLButtonElement|null} Currently active tune button reference */
let currentTuningButton = null;
/** @type {number} Currently selected match index in modal */
let selectedMatchIndex = 0;
/** @type {Array<HTMLElement>} Array of match item elements in modal */
let matchItems = [];
/** @type {boolean} Flag indicating if modal is currently open */
let isModalOpen = false;
let summaryMapInstance = null;
let summaryLayerGroup = null;
let currentMapMode = 'bbox';
let summaryRowData = [];
/**
* Stores CSV comment lines by line number for download functionality
* @type {Object<number, string>}
*/
// Comment map: stores CSV comments by line number
const commentMap = {{.Comments}};
// Modal elements
const modal = document.getElementById('locationModal');
const modalContent = document.querySelector('.modal-content');
const closeBtn = document.querySelector('.close');
// Modal functions
closeBtn.onclick = function() {
closeModal();
};
closeBtn.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
closeModal();
}
});
window.onclick = function(event) {
if (event.target == modal) {
closeModal();
}
};
/**
* Closes the location matches modal and cleans up resources
* @returns {void}
*/
function closeModal() {
if (!isModalOpen) return;
modal.style.display = 'none';
isModalOpen = false;
currentTuningButton = null;
document.body.style.overflow = '';
Object.values(maps).forEach(map => {
if (map && map.remove) {
map.remove();
}
});
maps = {};
matchItems = [];
}
// Modal keyboard navigation
modal.addEventListener('keydown', function(event) {
if (!isModalOpen) return;
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
closeModal();
return;
}
if (event.key === 'Enter') {
event.preventDefault();
if (matchItems[selectedMatchIndex]) {
matchItems[selectedMatchIndex].click();
}
return;
}
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
event.preventDefault();
navigateMatches(-1);
return;
}
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault();
navigateMatches(1);
return;
}
if (event.key === '+' || event.key === '=') {
event.preventDefault();
zoomCurrentMap(1);
return;
}
if (event.key === '-' || event.key === '_') {
event.preventDefault();
zoomCurrentMap(-1);
return;
}
});
document.addEventListener('keydown', function(event) {
if ((event.key === 'Escape' || event.key === 'Esc') && isModalOpen) {
event.preventDefault();
closeModal();
}
});
/**
* Navigates between location match items with arrow keys
* @param {number} direction - Navigate direction: positive (right/down) or negative (left/up)
* @returns {void}
*/
function navigateMatches(direction) {
if (matchItems.length === 0) return;
if (matchItems[selectedMatchIndex]) {
matchItems[selectedMatchIndex].classList.remove('selected');
}
selectedMatchIndex = (selectedMatchIndex + direction + matchItems.length) % matchItems.length;
if (matchItems[selectedMatchIndex]) {
matchItems[selectedMatchIndex].classList.add('selected');
matchItems[selectedMatchIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}
}
/**
* Zooms the currently selected map in or out
* @param {number} direction - Zoom direction: positive to zoom in, negative to zoom out
* @returns {void}
*/
function zoomCurrentMap(direction) {
const mapId = `map-${selectedMatchIndex}`;
const map = maps[mapId];
if (map) {
const currentZoom = map.getZoom();
map.setZoom(currentZoom + direction);
}
}
/**
* API endpoint for place search functionality
* @type {string}
*/
const API_ENDPOINT = 'https://mcp.fastah.ai/rest/geofeeds/place-search';
/**
* Fetches geolocation matches from the API for given location data
* @async
* @param {Array<Object>} rows - Array of row objects with location data (countryCode, regionCode, cityName)
* @returns {Promise<{results: Array<Object>}|null>} API response with matches or null on error
*/
async function fetchPlaceMatches(rows) {
const MAX_BATCH_SIZE = 1000;
let allResults = [];
let batchCount = Math.ceil(rows.length / MAX_BATCH_SIZE);
let lastError = null;
for (let i = 0; i < batchCount; i++) {
const batchRows = rows.slice(i * MAX_BATCH_SIZE, (i + 1) * MAX_BATCH_SIZE);
try {
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ rows: batchRows })
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data && data.results) {
allResults = allResults.concat(data.results);
} else {
lastError = 'No results in API response';
}
} catch (error) {
console.error('Error calling place search API:', error);
lastError = error.message;
}
}
if (allResults.length === 0 && lastError) {
alert(`Failed to fetch location data: ${lastError}`);
return null;
}
return { results: allResults };
}
/**
* Prepares geofeed row data for API request
* @param {HTMLTableRowElement} dataRow - The table row element to extract data from
* @returns {Object|null} Object with countryCode, regionCode, cityName, maxResults and searchMode, or null if required data missing
*/
function prepareRowForApi(dataRow) {
const cells = dataRow.querySelectorAll('td');
if (cells.length <= CELL_INDEX.STATUS) return null;
const countryCode = cells[CELL_INDEX.COUNTRY].textContent.trim();
const regionCode = cells[CELL_INDEX.REGION].textContent.trim();
const cityName = cells[CELL_INDEX.CITY].textContent.trim();
if (!countryCode) {
return null;
}
return {
rowKey: crypto.randomUUID(),
countryCode: countryCode,
regionCode: regionCode || '',
cityName: cityName || '',
searchMode: 'auto'
};
}
/**
* Handles tune button click - fetches location matches for selected issues
* @async
* @param {HTMLButtonElement} button - The tune button that was clicked
* @returns {Promise<void>}
*/
async function handleTuneButtonClick(button) {
currentTuningButton = button;
const issuesContainer = button.closest('td');
const checkedBoxes = issuesContainer.querySelectorAll('.message-line input[type="checkbox"]:checked');
const selectedIssues = Array.from(checkedBoxes).map(cb => {
const messageLine = cb.closest('.message-line');
return {
id: messageLine.getAttribute('data-id'),
message: messageLine.querySelector('span').textContent
};
});
if (selectedIssues.length === 0) {
alert('Please select at least one issue to tune.');
return;
}
const detailsRow = button.closest('tr');
// Find the data row - it's before the expand-details-rows
let dataRow = detailsRow.previousElementSibling;
while (dataRow && dataRow.classList.contains('expand-details-row')) {
dataRow = dataRow.previousElementSibling;
}
if (!dataRow || !dataRow.classList.contains('expandable-row')) {
console.error('Could not find data row');
return;
}
const rowData = prepareRowForApi(dataRow);
if (!rowData) {
alert('Missing required location data for API call');
return;
}
const apiResponse = await fetchPlaceMatches([rowData]);
if (!apiResponse || !apiResponse.results || apiResponse.results.length === 0) {
alert('No results returned from API');
return;
}
const result = apiResponse.results[0];
if (result.matches && result.matches.length > 0) {
showLocationPopup(result.matches, selectedIssues);
} else {
alert('No location matches found for this entry');
}
}
/**
* Displays the location matches popup modal with maps and selectable options
* @param {Array<Object>} matches - Array of location match objects from API
* @param {Array<Object>} [selectedIssues=[]] - Array of selected issues for tracking
* @returns {void}
*/
function showLocationPopup(matches, selectedIssues = []) {
if (!matches || matches.length === 0) {
return;
}
const matchesList = document.getElementById('matchesList');
matchesList.innerHTML = '';
maps = {};
matchItems = [];
selectedMatchIndex = 0;
matches.forEach((match, index) => {
const matchItem = document.createElement('div');
matchItem.className = 'match-item';
if (index === 0) {
matchItem.classList.add('selected');
}
const mapId = `map-${index}`;
// Determine primary location text: city > region > country
let primaryLocation = match.placeName;
let locationType = 'city';
if (!primaryLocation || primaryLocation.trim() === '') {
primaryLocation = match.stateCode;
locationType = 'region';
}
if (!primaryLocation || primaryLocation.trim() === '') {
primaryLocation = match.countryCode;
locationType = 'country';
}
// Build secondary info (show what's available)
let secondaryInfo = [];
if (match.placeName && locationType !== 'city') {
secondaryInfo.push(`City: ${escapeHtml(match.placeName)}`);
}
if (match.stateCode && locationType !== 'region') {
secondaryInfo.push(`Region: ${escapeHtml(match.stateCode)}`);
}
if (match.countryCode && locationType !== 'country') {
secondaryInfo.push(`Country: ${escapeHtml(match.countryCode)}`);
}
matchItem.innerHTML = `
<div id="${mapId}" class="match-map"></div>
<div class="match-info-container">
<div class="match-header">${escapeHtml(primaryLocation)}</div>
${secondaryInfo.length > 0 ? `<div class="location-details">${secondaryInfo.join(' \u2022 ')}</div>` : ''}
</div>
`;
matchItem.addEventListener('click', function() {
selectLocationMatch(match);
});
matchesList.appendChild(matchItem);
matchItems.push(matchItem);
setTimeout(() => {
initializeMapForMatch(mapId, match);
}, TIMING.MAP_INIT_DELAY);
});
modal.style.display = 'block';
isModalOpen = true;
document.body.style.overflow = 'hidden';
setTimeout(() => {
if (modalContent) {
modalContent.focus();
}
}, TIMING.MODAL_FOCUS_DELAY);
}
/**
* Selects a location match and updates the corresponding table row
* @param {Object} match - Match object with countryCode, regionCode, name, and boundingBox
* @returns {void}
*/
function selectLocationMatch(match) {
if (!currentTuningButton) {
console.error('No tuning button reference found');
return;
}
const detailsRow = currentTuningButton.closest('tr');
// Find the data row - it's before the expand-details-rows
let dataRow = detailsRow.previousElementSibling;
while (dataRow && dataRow.classList.contains('expand-details-row')) {
dataRow = dataRow.previousElementSibling;
}
if (!dataRow || !dataRow.classList.contains('expandable-row')) {
console.error('Could not find data row');
return;
}
updateRowWithMatchData(dataRow, match);
closeModal();
}
/**
* Updates a table row with selected match data and stores original values
* @param {HTMLTableRowElement} dataRow - The table row to update
* @param {Object} match - Match object with countryCode, regionCode, and name
* @returns {void}
*/
function updateRowWithMatchData(dataRow, match) {
const cells = dataRow.querySelectorAll('td');
const currentCountry = cells[CELL_INDEX.COUNTRY].textContent;
const currentRegion = cells[CELL_INDEX.REGION].textContent;
const currentCity = cells[CELL_INDEX.CITY].textContent;
if (!dataRow.hasAttribute('data-original-country')) {
dataRow.setAttribute('data-original-country', currentCountry);
dataRow.setAttribute('data-original-region', currentRegion);
dataRow.setAttribute('data-original-city', currentCity);
}
cells[CELL_INDEX.COUNTRY].textContent = match.countryCode;
cells[CELL_INDEX.REGION].textContent = match.stateCode;
cells[CELL_INDEX.CITY].textContent = match.placeName;
// Find the previous values row (it's the first expand-details-row after the data row)
const previousValuesRow = dataRow.nextElementSibling;
if (previousValuesRow && previousValuesRow.classList.contains('previous-values-row')) {
previousValuesRow.classList.add('show');
const defaultCountry = previousValuesRow.querySelector('.default-country');
const defaultRegion = previousValuesRow.querySelector('.default-region');
const defaultCity = previousValuesRow.querySelector('.default-city');
if (defaultCountry) defaultCountry.textContent = dataRow.getAttribute('data-original-country');
if (defaultRegion) defaultRegion.textContent = dataRow.getAttribute('data-original-region');
if (defaultCity) defaultCity.textContent = dataRow.getAttribute('data-original-city');
}
}
/**
* Initializes a Leaflet map with location boundary and marker for a match
* @param {string} mapId - The DOM element ID where map will render
* @param {Object} match - Match object with name, boundingBox, regionCode, countryCode
* @returns {void}
*/
function initializeMapForMatch(mapId, match) {
try {
const mapInstance = L.map(mapId, {
scrollWheelZoom: false,
dragging: true,
zoomControl: true
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 19
}).addTo(mapInstance);
const bbox = match.boundingBox;
if (bbox && bbox.length === 4) {
const [west, north, east, south] = bbox;
const centerLat = (south + north) / 2;
const centerLng = (west + east) / 2;
const isPoint = (west === east && north === south);
if (isPoint) {
const offset = 0.05;
const bounds = [
[south - offset, west - offset],
[north + offset, east + offset]
];
L.rectangle(bounds, {
color: '#dc3545',
weight: 3,
opacity: 1,
fillColor: '#dc3545',
fillOpacity: 0.2
}).addTo(mapInstance);
mapInstance.fitBounds(bounds, { padding: [30, 30] });
} else {
const bounds = [
[south, west],
[north, east]
];
L.rectangle(bounds, {
color: '#dc3545',
weight: 3,
opacity: 1,
fillColor: '#dc3545',
fillOpacity: 0.2
}).addTo(mapInstance);
mapInstance.fitBounds(bounds, { padding: [20, 20] });
}
L.marker([centerLat, centerLng]).addTo(mapInstance)
.bindPopup(`<b>${escapeHtml(match.placeName)}</b><br>${escapeHtml(match.stateCode)}`);
maps[mapId] = mapInstance;
setTimeout(() => {
mapInstance.invalidateSize();
}, TIMING.MAP_RESIZE_DELAY);
}
} catch (error) {
console.error('Error initializing map:', error);
}
}
/**
* Sets the report mode between 'viewing' and 'tuning'
* Tuning mode shows checkboxes and expands rows with issues
* @param {string} mode - 'viewing' or 'tuning'
* @returns {void}
*/
function setMode(mode) {
const toggle = document.getElementById('modeToggle');
const viewingText = document.getElementById('viewingText');
const tuningText = document.getElementById('tuningText');
if (mode === 'tuning') {
document.body.classList.add('tuning-mode');
toggle.classList.add('tuning');
viewingText.classList.remove('active');
tuningText.classList.add('active');
// Only expand rows with issues
const expandableRows = document.querySelectorAll('.expandable-row');
expandableRows.forEach(row => {
const hasError = row.getAttribute('data-has-error') === 'true';
const hasWarning = row.getAttribute('data-has-warning') === 'true';
const hasSuggestion = row.getAttribute('data-has-suggestion') === 'true';
if (hasError || hasWarning || hasSuggestion) {
let detailRow = row.nextElementSibling;
while (detailRow && detailRow.classList.contains('expand-details-row')) {
// Only show issues-row, not previous-values-row
if (detailRow.classList.contains('issues-row')) {
detailRow.classList.add('show');
}
detailRow = detailRow.nextElementSibling;
}
}
});
} else {
document.body.classList.remove('tuning-mode');
toggle.classList.remove('tuning');
viewingText.classList.add('active');
tuningText.classList.remove('active');
document.querySelectorAll('.expand-details-row').forEach(row => {
row.classList.remove('show');
});
}
// Refresh pagination after mode change
updatePagination();
}
/**
* Toggles between viewing and tuning modes
* @returns {void}
*/
function toggleMode() {
const isTuning = document.body.classList.contains('tuning-mode');
setMode(isTuning ? 'viewing' : 'tuning');
}
/**
* Escapes HTML special characters to prevent XSS attacks
* @param {string} text - Text to escape
* @returns {string} Escaped HTML text
*/
function escapeHtml(text) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, m => map[m]);
}
/**
* Filters the entries table based on current filter input values
* Resets to page 1 and updates pagination
* @returns {void}
*/
function filterTable() {
const countryFilter = document.getElementById('filterCountry').value.toLowerCase();
const regionFilter = document.getElementById('filterRegion').value.toLowerCase();
const cityFilter = document.getElementById('filterCity').value.toLowerCase();
const statusFilter = document.getElementById('filterStatus').value;
const ipPrefixFilter = document.getElementById('filterIpPrefix').value.toLowerCase();
const allExpandableRows = document.querySelectorAll('.expandable-row');
// Filter rows and update allRows array
allRows = Array.from(allExpandableRows).filter(row => {
const cells = row.querySelectorAll('td');
if (cells.length <= CELL_INDEX.STATUS) return false;
const rowData = {
country: cells[CELL_INDEX.COUNTRY].textContent.toLowerCase(),
region: cells[CELL_INDEX.REGION].textContent.toLowerCase(),
city: cells[CELL_INDEX.CITY].textContent.toLowerCase(),
status: cells[CELL_INDEX.STATUS].textContent,
ipPrefix: cells[CELL_INDEX.IP_PREFIX].textContent.toLowerCase()
};
return (
(!countryFilter || rowData.country.includes(countryFilter)) &&
(!regionFilter || rowData.region.includes(regionFilter)) &&
(!cityFilter || rowData.city.includes(cityFilter)) &&
(!statusFilter || rowData.status === statusFilter) &&
(!ipPrefixFilter || rowData.ipPrefix.includes(ipPrefixFilter))
);
});
// Reset to page 1 and update pagination
currentPage = 1;
updatePagination();
}
/**
* Pagination state and configuration
* @type {Object}
*/
// Pagination state
/** @type {number} Current page number (1-indexed) */
let currentPage = 1;
/** @type {number} Number of rows to display per page */
let rowsPerPage = 100;
/** @type {Array<HTMLTableRowElement>} Array of expandable rows after filtering */
let allRows = [];
// Initialize on page load
window.addEventListener('DOMContentLoaded', function() {
// Initialize pagination
allRows = Array.from(document.querySelectorAll('.expandable-row'));
initializePagination();
// Setup select all checkbox
const selectAllCheckbox = document.getElementById('selectAll');
const rowCheckboxes = document.querySelectorAll('.row-checkbox');
selectAllCheckbox.addEventListener('change', function() {
rowCheckboxes.forEach(checkbox => {
checkbox.checked = selectAllCheckbox.checked;
});
});
// Update select all checkbox when individual checkboxes change
rowCheckboxes.forEach(checkbox => {
checkbox.addEventListener('click', function(e) {
e.stopPropagation(); // Prevent row expansion when clicking checkbox
});
checkbox.addEventListener('keydown', function(e) {
if (e.key === ' ' || e.key === 'Enter') {
e.stopPropagation(); // Prevent row expansion when using keyboard on checkbox
}
});
checkbox.addEventListener('change', function(e) {
e.stopPropagation(); // Prevent row expansion when clicking checkbox
const allChecked = Array.from(rowCheckboxes).every(cb => cb.checked);
const noneChecked = Array.from(rowCheckboxes).every(cb => !cb.checked);
selectAllCheckbox.checked = allChecked;
selectAllCheckbox.indeterminate = !allChecked && !noneChecked;
});
});
// Setup expandable rows
const expandableRows = document.querySelectorAll('.expandable-row');
expandableRows.forEach(row => {
const hasError = row.getAttribute('data-has-error') === 'true';
const hasWarning = row.getAttribute('data-has-warning') === 'true';
const hasSuggestion = row.getAttribute('data-has-suggestion') === 'true';
// Only make rows with issues focusable
if (hasError || hasWarning || hasSuggestion) {
row.setAttribute('tabindex', '0');
// Click handler
row.addEventListener('click', function(e) {
// Don't expand if clicking on checkbox
if (e.target.classList.contains('row-checkbox')) {
return;
}
let detailRow = this.nextElementSibling;
let expanding = false;
// First pass: check if we are expanding (issues-row will be shown)
while (detailRow && detailRow.classList.contains('expand-details-row')) {
if (detailRow.classList.contains('issues-row')) {
expanding = !detailRow.classList.contains('show');
break;
}
detailRow = detailRow.nextElementSibling;
}
detailRow = this.nextElementSibling;
while (detailRow && detailRow.classList.contains('expand-details-row')) {
if (detailRow.classList.contains('issues-row')) {
// Always toggle issues-row
detailRow.classList.toggle('show');
} else if (detailRow.classList.contains('previous-values-row')) {
if (expanding) {
// Show previous-values-row if this row has original data (was tuned)
if (row.hasAttribute('data-original-country') ||
row.hasAttribute('data-original-region') ||
row.hasAttribute('data-original-city')) {
detailRow.classList.add('show');
}
} else {
// If collapsing, hide previous-values-row
detailRow.classList.remove('show');
}
}
detailRow = detailRow.nextElementSibling;
}
// Refresh pagination to show/hide the toggled detail rows
updatePagination();
});
// Keyboard handler
row.addEventListener('keydown', function(e) {
// Don't handle if focus is on checkbox
if (document.activeElement.classList.contains('row-checkbox')) {
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
let detailRow = this.nextElementSibling;
let expanding = false;
// First pass: check if we are expanding (issues-row will be shown)
while (detailRow && detailRow.classList.contains('expand-details-row')) {
if (detailRow.classList.contains('issues-row')) {
expanding = !detailRow.classList.contains('show');
break;
}
detailRow = detailRow.nextElementSibling;
}
detailRow = this.nextElementSibling;
while (detailRow && detailRow.classList.contains('expand-details-row')) {
if (detailRow.classList.contains('issues-row')) {
// Always toggle issues-row
detailRow.classList.toggle('show');
} else if (detailRow.classList.contains('previous-values-row')) {
if (expanding) {
// Show previous-values-row if this row has original data (was tuned)
if (row.hasAttribute('data-original-country') ||
row.hasAttribute('data-original-region') ||
row.hasAttribute('data-original-city')) {
detailRow.classList.add('show');
}
} else {
// If collapsing, hide previous-values-row
detailRow.classList.remove('show');
}
}
detailRow = detailRow.nextElementSibling;
}
// Refresh pagination to show/hide the toggled detail rows
updatePagination();
}
});
} else {
row.style.cursor = 'default';
}
});
// Toggle Expand All button
let isExpanded = false;
document.getElementById('toggleExpandAll').addEventListener('click', function() {
const expandableRows = document.querySelectorAll('.expandable-row');
const button = document.getElementById('toggleExpandAll');
if (!isExpanded) {
// Show all
expandableRows.forEach(row => {
const checkbox = row.querySelector('.row-checkbox');
if (!checkbox || !checkbox.checked) return; // Only work on checked rows
const hasError = row.getAttribute('data-has-error') === 'true';
const hasWarning = row.getAttribute('data-has-warning') === 'true';
const hasSuggestion = row.getAttribute('data-has-suggestion') === 'true';
// Only expand rows with issues
if (hasError || hasWarning || hasSuggestion) {
let detailRow = row.nextElementSibling;
while (detailRow && detailRow.classList.contains('expand-details-row')) {
// Show issues-row always
if (detailRow.classList.contains('issues-row')) {
detailRow.classList.add('show');
} else if (detailRow.classList.contains('previous-values-row')) {
// Show previous-values-row if this row has original data (was tuned)
if (row.hasAttribute('data-original-country') ||
row.hasAttribute('data-original-region') ||
row.hasAttribute('data-original-city')) {
detailRow.classList.add('show');
}
}
detailRow = detailRow.nextElementSibling;
}
}
});
button.innerHTML = '<i class="bi bi-arrows-collapse" style="margin-right: 5px;"></i>Collapse Rows';
isExpanded = true;
} else {
// Collapse all
expandableRows.forEach(row => {
const checkbox = row.querySelector('.row-checkbox');
if (!checkbox || !checkbox.checked) return; // Only work on checked rows
let detailRow = row.nextElementSibling;
while (detailRow && detailRow.classList.contains('expand-details-row')) {
detailRow.classList.remove('show');
detailRow = detailRow.nextElementSibling;
}
});
button.innerHTML = '<i class="bi bi-arrows-expand" style="margin-right: 5px;"></i>Expand Rows';
isExpanded = false;
}
// Refresh pagination to show expanded rows correctly
updatePagination();
});
// Toggle Filters button
document.getElementById('toggleFilters').addEventListener('click', function() {
const filterRow = document.getElementById('filterRow');
const button = document.getElementById('toggleFilters');
if (filterRow.style.display === 'none') {
filterRow.style.display = '';
button.innerHTML = '<i class="bi bi-funnel-fill" style="margin-right: 5px;"></i>Hide Filters';
} else {
filterRow.style.display = 'none';
button.innerHTML = '<i class="bi bi-funnel-fill" style="margin-right: 5px;"></i>Show Filters';
}
});
// Tune All button
document.getElementById('tuneAll').addEventListener('click', async function() {
const expandableRows = document.querySelectorAll('.expandable-row');
let updatedCount = 0;
let skippedCount = 0;
expandableRows.forEach(dataRow => {
const checkbox = dataRow.querySelector('.row-checkbox');
if (!checkbox || !checkbox.checked) {
skippedCount++;
return; // Only work on checked rows
}
const isTunable = dataRow.getAttribute('data-tunable') === 'true';
if (!isTunable) {
skippedCount++;
return;
}
const tunedCountry = dataRow.getAttribute('data-tuned-country');
const tunedRegion = dataRow.getAttribute('data-tuned-region');
const tunedCity = dataRow.getAttribute('data-tuned-city');
if (!tunedCountry && !tunedRegion && !tunedCity) {
skippedCount++;
return;
}
const cells = dataRow.querySelectorAll('td');
if (cells.length <= CELL_INDEX.STATUS) {
skippedCount++;
return;
}
if (!dataRow.hasAttribute('data-original-country')) {
dataRow.setAttribute('data-original-country', cells[CELL_INDEX.COUNTRY].textContent);
dataRow.setAttribute('data-original-region', cells[CELL_INDEX.REGION].textContent);
dataRow.setAttribute('data-original-city', cells[CELL_INDEX.CITY].textContent);
}
if (tunedCountry) cells[CELL_INDEX.COUNTRY].textContent = tunedCountry;
if (tunedRegion) cells[CELL_INDEX.REGION].textContent = tunedRegion;
if (tunedCity) cells[CELL_INDEX.CITY].textContent = tunedCity;
// Find the previous values row
const previousValuesRow = dataRow.nextElementSibling;
if (previousValuesRow && previousValuesRow.classList.contains('previous-values-row')) {
previousValuesRow.classList.add('show');
const defaultCountry = previousValuesRow.querySelector('.default-country');
const defaultRegion = previousValuesRow.querySelector('.default-region');
const defaultCity = previousValuesRow.querySelector('.default-city');
if (defaultCountry) defaultCountry.textContent = dataRow.getAttribute('data-original-country');
if (defaultRegion) defaultRegion.textContent = dataRow.getAttribute('data-original-region');
if (defaultCity) defaultCity.textContent = dataRow.getAttribute('data-original-city');
}
updatedCount++;
});
// Refresh pagination to update displayed values
updatePagination();
alert(`Successfully updated ${updatedCount} rows (${skippedCount} rows skipped - not tunable or no tuned values available)`);
});
// Add filter event listeners
document.getElementById('filterCountry').addEventListener('input', filterTable);
document.getElementById('filterRegion').addEventListener('input', filterTable);
document.getElementById('filterCity').addEventListener('input', filterTable);
document.getElementById('filterStatus').addEventListener('change', filterTable);
document.getElementById('filterIpPrefix').addEventListener('input', filterTable);
// Reset filters button
document.getElementById('resetFilters').addEventListener('click', function() {
document.getElementById('filterCountry').value = '';
document.getElementById('filterRegion').value = '';
document.getElementById('filterCity').value = '';
document.getElementById('filterStatus').value = '';
document.getElementById('filterIpPrefix').value = '';
// Reset to all rows and update pagination
allRows = Array.from(document.querySelectorAll('.expandable-row'));
currentPage = 1;
updatePagination();
});
// Download CSV button
document.getElementById('downloadCSV').addEventListener('click', function() {
const rows = document.querySelectorAll('.expandable-row');
const rowDataMap = {};
// Collect all data rows with their line numbers
rows.forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length > CELL_INDEX.CITY) {
const lineNum = parseInt(cells[CELL_INDEX.LINE].textContent.trim());
const ipPrefix = cells[CELL_INDEX.IP_PREFIX].textContent.trim();
const country = cells[CELL_INDEX.COUNTRY].textContent.trim();
const region = cells[CELL_INDEX.REGION].textContent.trim();
const city = cells[CELL_INDEX.CITY].textContent.trim();
rowDataMap[lineNum] = [ipPrefix, country, region, city];
}
});
// Build CSV content with comments in the correct positions
const csvLines = [];
// Find max line number
const allLineNumbers = Object.keys(rowDataMap).map(n => parseInt(n));
const maxLine = Math.max(...allLineNumbers);
// Iterate through all line numbers from 1 to maxLine
for (let lineNum = 1; lineNum <= maxLine; lineNum++) {
if (lineNum in commentMap) {
// This line is a comment
csvLines.push(commentMap[lineNum]);
} else if (rowDataMap[lineNum]) {
// This line is a data row
const row = rowDataMap[lineNum];
const csvRow = row.map(cell => {
// Escape quotes and wrap in quotes if contains comma, quote, or newline
const cellStr = String(cell);
if (cellStr.includes(',') || cellStr.includes('"') || cellStr.includes('\n')) {
return '"' + cellStr.replace(/"/g, '""') + '"';
}
return cellStr;
}).join(',');
csvLines.push(csvRow);
}
// If neither comment nor data, skip the line (maintains original CSV structure)
}
const csvContent = csvLines.join('\n');
// Create download link
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
// Get filename from inputFileMetrics, ensure it has .csv extension
let filename =
document.getElementById('inputFileMetrics')
.textContent
.trim()
.split(/[/\\]/)
.pop() || 'geofeed_report.csv';
if (!filename.toLowerCase().endsWith('.csv')) {
filename += '.csv';
}
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
// Pagination event listeners
document.getElementById('pageSize').addEventListener('change', function() {
const value = this.value;
if (value === 'all') {
rowsPerPage = allRows.length;
} else {
rowsPerPage = parseInt(value);
}
currentPage = 1;
updatePagination();
});
document.getElementById('firstPage').addEventListener('click', function() {
currentPage = 1;
updatePagination();
});
document.getElementById('prevPage').addEventListener('click', function() {
if (currentPage > 1) {
currentPage--;
updatePagination();
}
});
document.getElementById('nextPage').addEventListener('click', function() {
const totalPages = Math.ceil(allRows.length / rowsPerPage);
if (currentPage < totalPages) {
currentPage++;
updatePagination();
}
});
document.getElementById('lastPage').addEventListener('click', function() {
currentPage = Math.ceil(allRows.length / rowsPerPage);
updatePagination();
});
initSummaryMap();
});
/**
* Initializes pagination on page load
* @returns {void}
*/
function initializePagination() {
updatePagination();
}
function initSummaryMap() {
try {
const summaryMapEl = document.getElementById('summaryMap');
if (!summaryMapEl) return;
summaryMapInstance = L.map('summaryMap', {
scrollWheelZoom: true,
dragging: true,
zoomControl: true,
center: [20, 0],
zoom: 2
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '\u00a9 <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(summaryMapInstance);
summaryLayerGroup = L.layerGroup().addTo(summaryMapInstance);
// Parse and cache all row data once
summaryRowData = [];
const expandableRows = document.querySelectorAll('.expandable-row');
expandableRows.forEach(row => {
const cells = row.querySelectorAll('td');
const hasError = row.getAttribute('data-has-error') === 'true';
const hasWarning = row.getAttribute('data-has-warning') === 'true';
const hasSugg = row.getAttribute('data-has-suggestion') === 'true';
let color = '#28a745';
if (hasError) color = '#dc3545';
else if (hasWarning) color = '#fd7e14';
else if (hasSugg) color = '#17a2b8';
const country = cells[CELL_INDEX.COUNTRY] ? cells[CELL_INDEX.COUNTRY].textContent.trim() : '';
const region = cells[CELL_INDEX.REGION] ? cells[CELL_INDEX.REGION].textContent.trim() : '';
const city = cells[CELL_INDEX.CITY] ? cells[CELL_INDEX.CITY].textContent.trim() : '';
const parts = [];
if (city && region && country) {
parts.push('<b>' + escapeHtml(city) + '</b>' + ' • ' + escapeHtml(region) + ' • ' + escapeHtml(country));
} else if (city && country) {
parts.push('<b>' + escapeHtml(city) + '</b>' + ' • ' + escapeHtml(country));
} else if (region && country) {
parts.push('<b>' + escapeHtml(region) + '</b>' + ' • ' + escapeHtml(country));
} else if (country) {
parts.push('<b>' + escapeHtml(country) + '</b>');
}
summaryRowData.push({
rowId: row.id,
bbox: row.getAttribute('data-bounding-box') || '',
h3cells: row.getAttribute('data-h3-cells') || '',
color: color,
popup: parts.join('<br>')
});
});
setTimeout(() => {
summaryMapInstance.invalidateSize();
switchMapMode('bbox');
}, 350);
} catch (err) {
console.error('initSummaryMap error:', err);
}
}
function switchMapMode(mode) {
const toggle = document.getElementById('mapModeToggle');
const bboxText = document.getElementById('mapModeBboxText');
const h3Text = document.getElementById('mapModeH3Text');
// If called with no argument, act as a toggle
if (mode === undefined) {
mode = currentMapMode === 'bbox' ? 'h3' : 'bbox';
}
if (mode === 'h3') {
toggle.classList.add('h3mode');
bboxText.classList.remove('active');
h3Text.classList.add('active');
} else {
toggle.classList.remove('h3mode');
bboxText.classList.add('active');
h3Text.classList.remove('active');
}
renderSummaryMapLayers(mode);
}
function renderSummaryMapLayers(mode) {
if (!summaryMapInstance || !summaryLayerGroup) return;
currentMapMode = mode;
summaryLayerGroup.clearLayers();
// Determine which row IDs are visible on the current page
const startIndex = (currentPage - 1) * rowsPerPage;
const endIndex = Math.min(startIndex + rowsPerPage, allRows.length);
const visibleIds = new Set();
for (let i = startIndex; i < endIndex; i++) {
visibleIds.add(allRows[i].id);
}
const tempBoundsLayers = [];
const seenBboxKeys = new Set(); // deduplicate identical bounding boxes / h3 cell sets
summaryRowData.forEach(data => {
// Only render entries whose row is on the current page
if (!visibleIds.has(data.rowId)) return;
if (mode === 'bbox') {
const raw = data.bbox.replace(/[\[\]]/g, '').trim();
const pts = raw.split(/[\s,]+/).map(Number);
if (pts.length !== 4 || pts.some(v => isNaN(v))) return;
// Skip duplicate bounding boxes
const bboxKey = pts.join(',');
if (seenBboxKeys.has(bboxKey)) return;
seenBboxKeys.add(bboxKey);
const [west, north, east, south] = pts;
const lb = [[south, west], [north, east]];
const rect = L.rectangle(lb, {
color: data.color, weight: 3, opacity: 1,
fillColor: data.color, fillOpacity: 0.35
});
rect.bindPopup(data.popup);
summaryLayerGroup.addLayer(rect);
const cm = L.circleMarker([(south + north) / 2, (west + east) / 2], {
radius: 5, color: data.color, fillColor: data.color, fillOpacity: 0.9, weight: 1
});
cm.bindPopup(data.popup);
summaryLayerGroup.addLayer(cm);
tempBoundsLayers.push(L.rectangle(lb));
} else {
// H3 cells mode
const raw = data.h3cells.replace(/[\[\]]/g, '').trim();
if (!raw) return;
// Skip duplicate h3 cell sets
if (seenBboxKeys.has(raw)) return;
seenBboxKeys.add(raw);
const cellIds = raw.split(/[\s,]+/).filter(Boolean);
cellIds.forEach(cellId => {
try {
const boundary = h3.cellToBoundary(cellId); // returns [[lat,lng],...]
const poly = L.polygon(boundary, {
color: data.color, weight: 2, opacity: 0.9,
fillColor: data.color, fillOpacity: 0.35
});
poly.bindPopup(data.popup);
summaryLayerGroup.addLayer(poly);
const b = poly.getBounds();
tempBoundsLayers.push(L.rectangle([[b.getSouth(), b.getWest()], [b.getNorth(), b.getEast()]]));
} catch (e) {
console.warn('H3 render error for cell', cellId, e);
}
});
}
});
if (tempBoundsLayers.length > 0) {
const group = L.featureGroup(tempBoundsLayers);
summaryMapInstance.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: 14 });
}
}
/**
* Updates pagination display for current page
* Shows/hides rows based on current page and rowsPerPage
* @returns {void}
*/
function updatePagination() {
const totalPages = Math.max(1, Math.ceil(allRows.length / rowsPerPage));
// Ensure currentPage is within valid range
if (currentPage > totalPages) {
currentPage = totalPages;
}
if (currentPage < 1) {
currentPage = 1;
}
const startIndex = (currentPage - 1) * rowsPerPage;
const endIndex = Math.min(startIndex + rowsPerPage, allRows.length);
// Hide ALL expandable rows first (not just filtered ones)
const allExpandableRows = document.querySelectorAll('.expandable-row');
allExpandableRows.forEach(row => {
row.style.display = 'none';
// Hide associated detail rows
let nextRow = row.nextElementSibling;
while (nextRow && nextRow.classList.contains('expand-details-row')) {
nextRow.style.display = 'none';
nextRow = nextRow.nextElementSibling;
}
});
// Show rows for current page
for (let i = startIndex; i < endIndex; i++) {
const row = allRows[i];
row.style.display = '';
// Show associated detail rows if they have 'show' class
let nextRow = row.nextElementSibling;
while (nextRow && nextRow.classList.contains('expand-details-row')) {
if (nextRow.classList.contains('show')) {
nextRow.style.display = '';
}
nextRow = nextRow.nextElementSibling;
}
}
// Update pagination info
if (allRows.length === 0) {
document.getElementById('paginationInfo').textContent = 'Showing 0 of 0 rows';
} else {
document.getElementById('paginationInfo').textContent =
`Showing ${startIndex + 1}-${endIndex} of ${allRows.length} rows`;
}
// Update button states
document.getElementById('firstPage').disabled = currentPage === 1 || allRows.length === 0;
document.getElementById('prevPage').disabled = currentPage === 1 || allRows.length === 0;
document.getElementById('nextPage').disabled = currentPage === totalPages || allRows.length === 0;
document.getElementById('lastPage').disabled = currentPage === totalPages || allRows.length === 0;
// Update page numbers
updatePageNumbers(totalPages);
// Refresh the Geographic Coverage Map to show only current-page entries
renderSummaryMapLayers(currentMapMode);
}
/**
* Updates page number buttons display with ellipsis for large page counts
* @param {number} totalPages - Total number of pages available
* @returns {void}
*/
function updatePageNumbers(totalPages) {
const pageNumbersContainer = document.getElementById('pageNumbers');
pageNumbersContainer.innerHTML = '';
// Show max 5 page numbers with ellipsis
const maxVisible = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2));
let endPage = Math.min(totalPages, startPage + maxVisible - 1);
// Adjust if we're near the end
if (endPage - startPage < maxVisible - 1) {
startPage = Math.max(1, endPage - maxVisible + 1);
}
// Add first page and ellipsis if needed
if (startPage > 1) {
addPageButton(1);
if (startPage > 2) {
const ellipsis = document.createElement('span');
ellipsis.textContent = '...';
ellipsis.style.padding = '6px 8px';
ellipsis.style.color = '#333';
pageNumbersContainer.appendChild(ellipsis);
}
}
// Add page numbers
for (let i = startPage; i <= endPage; i++) {
addPageButton(i);
}
// Add ellipsis and last page if needed
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
const ellipsis = document.createElement('span');
ellipsis.textContent = '...';
ellipsis.style.padding = '6px 8px';
ellipsis.style.color = '#333';
pageNumbersContainer.appendChild(ellipsis);
}
addPageButton(totalPages);
}
}
/**
* Creates and adds a page number button to pagination controls
* @param {number} pageNum - Page number for the button
* @returns {void}
*/
function addPageButton(pageNum) {
const pageNumbersContainer = document.getElementById('pageNumbers');
const btn = document.createElement('button');
btn.className = 'pagination-btn' + (pageNum === currentPage ? ' active' : '');
btn.textContent = pageNum;
btn.addEventListener('click', function() {
currentPage = pageNum;
updatePagination();
});
pageNumbersContainer.appendChild(btn);
}
</script>
</body>
</html>
Requirements and compatibility
Written for: GitHub Copilot
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs an MCP server, Needs Python
The author says: Requires Python 3
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Created by
- Sid Mathur
- Publisher
- GitHub (Official)
- Distributed by
- GitHub
- Source
- github/awesome-copilot · skills/geofeed-tuner · commit 797337f
- Licence
- Apache License 2.0 (redistribution allowed)
- LICENSE (written by AutoGineer)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Sid Mathur or GitHub.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download geofeed-tuner.zip