Compare commits

...

3 Commits

Author SHA1 Message Date
trunksbomb
5c5c35871d ready for initial release
Some checks failed
Release / publish (release) Has been cancelled
2026-03-23 10:58:48 -04:00
trunksbomb
5d283909db first pass on README
Some checks failed
Build / build (push) Has been cancelled
2026-03-23 05:01:55 -04:00
trunksbomb
8189359e91 add a "not fully supported" tooltip for bags that use the fallback hotbar method 2026-03-23 04:44:38 -04:00
8 changed files with 536 additions and 21 deletions

View File

@@ -0,0 +1,43 @@
name: Release
on:
release:
types:
- published
jobs:
publish:
runs-on: ubuntu-latest
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
BAGTABS_GITEA_TOKEN: ${{ secrets.BAGTABS_GITEA_TOKEN }}
MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }}
CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }}
MODRINTH_PROJECT_ID: ${{ vars.MODRINTH_PROJECT_ID }}
CURSEFORGE_PROJECT_ID: ${{ vars.CURSEFORGE_PROJECT_ID }}
MINECRAFT_VERSIONS: ${{ vars.MINECRAFT_VERSIONS }}
MOD_LOADERS: ${{ vars.MOD_LOADERS }}
CURSEFORGE_GAME_VERSION_NAMES: ${{ vars.CURSEFORGE_GAME_VERSION_NAMES }}
CURSEFORGE_GAME_VERSION_IDS: ${{ vars.CURSEFORGE_GAME_VERSION_IDS }}
RELEASE_ARTIFACT_GLOB: ${{ vars.RELEASE_ARTIFACT_GLOB }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Make Gradle wrapper executable
run: chmod +x ./gradlew
- name: Build release jar
run: ./gradlew --no-daemon clean build
- name: Publish release artifacts
run: python3 ./scripts/publish_release.py

109
README.md
View File

@@ -1,25 +1,96 @@
# Bag Tabs
Installation information Bag Tabs is a NeoForge mod for Minecraft 1.21.1 that adds a tab strip to inventory screens for bags and other portable storage items you are carrying.
=======
This template repository can be directly cloned to get you started with a new The goal is simple: if you are carrying a bag, you should be able to open it directly from your inventory without moving it into your hand first.
mod. Simply create a new repository cloned from this one, by following the
instructions provided by [GitHub](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template).
Once you have your clone, simply open the repository in the IDE of your choice. The usual recommendation for an IDE is either IntelliJ IDEA or Eclipse. ## What It Does
If at any point you are missing libraries in your IDE, or you've run into problems you can - Adds a tab bar to supported inventory screens.
run `gradlew --refresh-dependencies` to refresh the local cache. `gradlew clean` to reset everything - Shows one tab for each carried bag or portable storage item the mod recognizes.
{this does not affect your code} and then start the process again. - Lets you click a tab to open that bag directly.
- Keeps the tab bar visible while you move between supported bag screens.
- Highlights the currently open bag.
- Supports drag-and-drop insertion onto supported bag tabs when the bag can accept the carried stack.
Mapping Names: ## Included Content
============
By default, the MDK is configured to use the official mapping names from Mojang for methods and fields
in the Minecraft codebase. These names are covered by a specific license. All modders should be aware of this
license. For the latest license text, refer to the mapping file itself, or the reference copy here:
https://github.com/NeoForged/NeoForm/blob/main/Mojang.md
Additional Resources: - A built-in Bag Tabs bag item.
========== - 27 inventory slots, like a single chest.
Community Documentation: https://docs.neoforged.net/ - Dyeable bag colors.
NeoForged Discord: https://discord.neoforged.net/ - Crafting recipe.
- Creative tab with the base bag, all dyed variants, and the Bag Namer tool.
- JEI entries for the dyed bag variants.
## Tab Features
- Bag tabs use the bag item icon for normal mode.
- Compact mode replaces the icon with the bag name.
- Dyed bags tint their tab background.
- Pinned tabs always appear first.
- Pinned tabs can be reordered by drag-and-drop.
- Tabs can be locked to disable pinning and reordering.
- Overflow arrows appear automatically when more tabs exist than can be shown.
- Mouse wheel scrolling works over the dock.
- Fullness indicators can be shown on tabs and toggled in dock settings.
## Dock Features
- Dock to the GUI bottom, top, left, or right.
- Dock to the screen bottom, top, left, or right.
- Floating horizontal and floating vertical modes.
- Per-player, per-world saved settings.
- Global default settings plus per-screen overrides.
- Remembered scroll position per screen.
- Adjustable X/Y offset.
- Adjustable max visible tabs.
- Optional compact layout.
- Optional fullness indicator.
- Draggable in-game dock settings popup with live preview.
## Bag Opening Behavior
Bag Tabs supports two kinds of bag opening:
- Direct support for bags with native or compatibility-backed integration.
- A fallback hotbar method for unsupported portable storage items that still look like bags or containers.
When a bag is not fully supported, the tooltip says:
- `Mod not supported: uses hotbar method to open`
If no warning is shown, the bag is using native or compatibility-backed support.
## Current Compatibility
Bag Tabs currently includes compatibility support for:
- Bag Tabs bags
- Traveler's Backpack
- Sophisticated Backpacks
- Dank Storage
Other portable storage items may still appear as tabs and may still open through the fallback hotbar method.
## Bag Namer
Bag Tabs includes a Bag Namer tool that can rename portable storage items.
- Works on supported bags and inventory-like items.
- Uses an in-world style GUI with input and output slots.
- Does not close after each rename, so items can be renamed in batches.
- Drops its contents if you leave the screen.
## Controls
- Left-click tab: open bag
- Right-click tab: pin or unpin
- Drag pinned tab: reorder pinned tabs
- Mouse wheel over dock: scroll visible tabs
- `\` by default: open last bag
## Notes
- Bag Tabs is designed to stay attached to existing inventory screens rather than replacing them.
- Unsupported bags may behave differently depending on how their mod handles right-click opening.
- Some mods may still need dedicated compatibility if they do not expose normal item-use behavior.

View File

@@ -30,9 +30,9 @@ mod_id=bagtabs
# The human-readable display name for the mod. # The human-readable display name for the mod.
mod_name=Bag Tabs mod_name=Bag Tabs
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=All Rights Reserved mod_license=MIT
# The mod version. See https://semver.org/ # The mod version. See https://semver.org/
mod_version=1.0.0 mod_version=0.1.0
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources. # This should match the base package used for the mod sources.
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html # See https://maven.apache.org/guides/mini/guide-naming-conventions.html

BIN
project_picture.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 B

BIN
project_picture_full.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

397
scripts/publish_release.py Normal file
View File

@@ -0,0 +1,397 @@
#!/usr/bin/env python3
import glob
import json
import os
import pathlib
import subprocess
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
ROOT = pathlib.Path(__file__).resolve().parent.parent
GRADLE_PROPERTIES = ROOT / "gradle.properties"
def fail(message: str) -> None:
print(f"ERROR: {message}", file=sys.stderr)
raise SystemExit(1)
def read_gradle_properties() -> dict[str, str]:
values: dict[str, str] = {}
for line in GRADLE_PROPERTIES.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, value = stripped.split("=", 1)
values[key.strip()] = value.strip()
return values
def load_release_event() -> dict:
event_path = os.environ.get("GITHUB_EVENT_PATH")
if not event_path:
fail("GITHUB_EVENT_PATH is not set. This script must run inside a release workflow.")
with open(event_path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
if "release" not in payload:
fail("Workflow event payload does not contain release data.")
return payload
def env_required(name: str) -> str:
value = os.environ.get(name, "").strip()
if not value:
fail(f"Missing required environment value: {name}")
return value
def env_optional(name: str, default: str = "") -> str:
return os.environ.get(name, default).strip()
def first_non_empty(*values: str) -> str:
for value in values:
if value:
return value
return ""
def split_csv(value: str) -> list[str]:
return [part.strip() for part in value.split(",") if part.strip()]
def derive_release_channel(tag_name: str, release_name: str, prerelease: bool) -> str:
text = " ".join([tag_name, release_name]).lower()
if "alpha" in text:
return "alpha"
if prerelease or "beta" in text or "rc" in text or "pre" in text:
return "beta"
return "release"
def select_artifact(mod_id: str, version_hint: str) -> pathlib.Path:
pattern = env_optional("RELEASE_ARTIFACT_GLOB", "build/libs/*.jar")
candidates = [
pathlib.Path(path)
for path in glob.glob(str(ROOT / pattern))
if pathlib.Path(path).is_file()
]
filtered = [
path
for path in candidates
if not any(
marker in path.name
for marker in ("-sources", "-javadoc", "-api", "-dev", "-slim")
)
]
if not filtered:
fail(f"No release jar matched {pattern}")
version_name = version_hint[1:] if version_hint.startswith("v") else version_hint
exact_name = f"{mod_id}-{version_name}.jar"
for path in filtered:
if path.name == exact_name:
return path
filtered.sort(key=lambda path: (path.name.count("-"), len(path.name), path.name))
return filtered[0]
def http_json(method: str, url: str, headers: dict[str, str]) -> tuple[int, object]:
request = urllib.request.Request(url, method=method)
for key, value in headers.items():
request.add_header(key, value)
try:
with urllib.request.urlopen(request) as response:
body = response.read().decode("utf-8")
return response.status, json.loads(body) if body else None
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
return error.code, json.loads(body) if body else None
def curl_request(arguments: list[str], expected_codes: set[int]) -> str:
with tempfile.NamedTemporaryFile(delete=False) as output_file:
output_path = output_file.name
try:
command = [
"curl",
"--silent",
"--show-error",
"--location",
"--output",
output_path,
"--write-out",
"%{http_code}",
*arguments,
]
result = subprocess.run(command, capture_output=True, text=True, check=False)
if result.returncode != 0:
fail(result.stderr.strip() or "curl failed")
status_code = int(result.stdout.strip())
body = pathlib.Path(output_path).read_text(encoding="utf-8", errors="replace")
if status_code not in expected_codes:
fail(f"HTTP {status_code} from remote API:\n{body}")
return body
finally:
pathlib.Path(output_path).unlink(missing_ok=True)
def gitea_headers(token: str) -> dict[str, str]:
return {
"Accept": "application/json",
"Authorization": f"token {token}",
}
def attach_to_gitea_release(
server_url: str,
owner: str,
repo: str,
release_id: int,
artifact: pathlib.Path,
token: str,
) -> None:
asset_name = artifact.name
asset_base = (
f"{server_url.rstrip('/')}/api/v1/repos/"
f"{urllib.parse.quote(owner)}/{urllib.parse.quote(repo)}/releases/{release_id}/assets"
)
headers = gitea_headers(token)
status, payload = http_json("GET", asset_base, headers)
if status == 200 and isinstance(payload, list):
for asset in payload:
if asset.get("name") == asset_name:
delete_url = f"{asset_base}/{asset['id']}"
delete_status, _ = http_json("DELETE", delete_url, headers)
if delete_status not in (204, 404):
fail(f"Failed to delete existing Gitea release asset {asset_name}")
elif status != 404:
fail(f"Could not list Gitea release assets (HTTP {status})")
upload_url = f"{asset_base}?name={urllib.parse.quote(asset_name)}"
print(f"Uploading {asset_name} to Gitea release #{release_id}")
curl_request(
[
"--request",
"POST",
"--header",
f"Authorization: token {token}",
"--header",
"Accept: application/json",
"--form",
f"attachment=@{artifact}",
upload_url,
],
{201},
)
def upload_to_modrinth(
artifact: pathlib.Path,
token: str,
project_id: str,
version_number: str,
version_name: str,
changelog: str,
release_channel: str,
game_versions: list[str],
loaders: list[str],
) -> None:
payload = {
"project_id": project_id,
"version_number": version_number,
"version_title": version_name,
"version_type": release_channel,
"status": "listed",
"featured": False,
"loaders": loaders,
"game_versions": game_versions,
"changelog": changelog,
"file_parts": ["file"],
"primary_file": "file",
}
print(f"Publishing {artifact.name} to Modrinth project {project_id}")
curl_request(
[
"--request",
"POST",
"--header",
f"Authorization: {token}",
"--header",
"User-Agent: bagtabs-release-pipeline/1.0 (self-hosted Gitea)",
"--form",
f"data={json.dumps(payload)}",
"--form",
f"file=@{artifact}",
"https://api.modrinth.com/v2/version",
],
{200, 201},
)
def resolve_curseforge_game_version_ids(
token: str,
game_version_names: list[str],
explicit_ids: list[str],
api_base: str,
) -> list[int]:
if explicit_ids:
return [int(value) for value in explicit_ids]
if not game_version_names:
fail("Set CURSEFORGE_GAME_VERSION_IDS or CURSEFORGE_GAME_VERSION_NAMES for CurseForge publishing.")
url = f"{api_base.rstrip('/')}/api/game/versions"
request = urllib.request.Request(url, method="GET")
request.add_header("X-Api-Token", token)
try:
with urllib.request.urlopen(request) as response:
versions = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
fail(f"Could not read CurseForge game versions (HTTP {error.code}):\n{body}")
matches: list[int] = []
missing: list[str] = []
for name in game_version_names:
matched = next((entry for entry in versions if entry.get("name") == name), None)
if not matched:
missing.append(name)
else:
matches.append(int(matched["id"]))
if missing:
fail(
"Could not resolve CurseForge game version names: "
+ ", ".join(missing)
+ ". Set CURSEFORGE_GAME_VERSION_IDS to bypass lookup."
)
return matches
def upload_to_curseforge(
artifact: pathlib.Path,
token: str,
project_id: str,
display_name: str,
changelog: str,
release_channel: str,
game_version_ids: list[int],
api_base: str,
) -> None:
metadata = {
"changelog": changelog,
"changelogType": "markdown",
"displayName": display_name,
"gameVersions": game_version_ids,
"releaseType": release_channel,
}
url = f"{api_base.rstrip('/')}/api/projects/{project_id}/upload-file"
print(f"Publishing {artifact.name} to CurseForge project {project_id}")
curl_request(
[
"--request",
"POST",
"--header",
f"X-Api-Token: {token}",
"--form",
f"metadata={json.dumps(metadata)}",
"--form",
f"file=@{artifact}",
url,
],
{200, 201},
)
def main() -> None:
gradle_properties = read_gradle_properties()
event = load_release_event()
release = event["release"]
repository = event.get("repository", {})
repository_full_name = repository.get("full_name", "")
repository_parts = repository_full_name.split("/", 1) if "/" in repository_full_name else ["", ""]
mod_id = gradle_properties["mod_id"]
mod_name = gradle_properties["mod_name"]
tag_name = release.get("tag_name")
if not tag_name:
fail("Release tag name missing from event payload.")
version_number = tag_name[1:] if tag_name.startswith("v") else tag_name
version_name = release.get("name") or f"{mod_name} {version_number}"
changelog = release.get("body") or f"Release {version_name}"
release_channel = derive_release_channel(tag_name, version_name, bool(release.get("prerelease")))
artifact = select_artifact(mod_id, tag_name)
print(f"Selected artifact: {artifact}")
server_url = first_non_empty(env_optional("GITEA_SERVER_URL"), env_optional("GITHUB_SERVER_URL"))
if not server_url:
fail("Could not determine the Gitea server URL from workflow environment.")
owner = first_non_empty(
repository.get("owner", {}).get("login")
or repository.get("owner_name"),
repository_parts[0],
env_optional("GITEA_REPOSITORY_OWNER"),
)
repo = first_non_empty(
repository.get("name"),
repository_parts[1],
env_optional("GITEA_REPOSITORY_NAME"),
)
if not owner or not repo:
fail("Could not determine repository owner/name from the release event.")
attach_to_gitea_release(
server_url=server_url,
owner=owner,
repo=repo,
release_id=int(release["id"]),
artifact=artifact,
token=env_required("BAGTABS_GITEA_TOKEN"),
)
game_versions = split_csv(env_required("MINECRAFT_VERSIONS"))
loaders = split_csv(env_required("MOD_LOADERS"))
upload_to_modrinth(
artifact=artifact,
token=env_required("MODRINTH_TOKEN"),
project_id=env_required("MODRINTH_PROJECT_ID"),
version_number=version_number,
version_name=version_name,
changelog=changelog,
release_channel=release_channel,
game_versions=game_versions,
loaders=loaders,
)
curseforge_token = env_required("CURSEFORGE_TOKEN")
curseforge_api_base = env_optional("CURSEFORGE_API_BASE", "https://minecraft.curseforge.com")
curseforge_game_version_ids = resolve_curseforge_game_version_ids(
token=curseforge_token,
game_version_names=split_csv(env_optional("CURSEFORGE_GAME_VERSION_NAMES")),
explicit_ids=split_csv(env_optional("CURSEFORGE_GAME_VERSION_IDS")),
api_base=curseforge_api_base,
)
upload_to_curseforge(
artifact=artifact,
token=curseforge_token,
project_id=env_required("CURSEFORGE_PROJECT_ID"),
display_name=version_name,
changelog=changelog,
release_channel=release_channel,
game_version_ids=curseforge_game_version_ids,
api_base=curseforge_api_base,
)
print("Release publishing complete.")
if __name__ == "__main__":
main()

View File

@@ -1050,6 +1050,9 @@ public final class BagTabOverlay {
private static List<Component> getTooltipLines(RenderedTab tab, List<BagEntry> tabs) { private static List<Component> getTooltipLines(RenderedTab tab, List<BagEntry> tabs) {
List<Component> lines = new ArrayList<>(); List<Component> lines = new ArrayList<>();
lines.add(tab.entry().stack().getHoverName()); lines.add(tab.entry().stack().getHoverName());
if (tab.entry().handler() == null) {
lines.add(Component.translatable("bagtabs.tooltip.hotbar_fallback").withStyle(style -> style.withItalic(true)));
}
lines.add(Component.literal("Left-click: open")); lines.add(Component.literal("Left-click: open"));
if (tab.pinned()) { if (tab.pinned()) {
if (!DockConfigManager.isInteractionsLocked()) { if (!DockConfigManager.isInteractionsLocked()) {

View File

@@ -5,6 +5,7 @@
"container.bagtabs.bag": "Bag", "container.bagtabs.bag": "Bag",
"container.bagtabs.bag_namer": "Bag Namer", "container.bagtabs.bag_namer": "Bag Namer",
"bagtabs.tooltip.click_to_open": "Open from your inventory tabs", "bagtabs.tooltip.click_to_open": "Open from your inventory tabs",
"bagtabs.tooltip.hotbar_fallback": "This container is not fully supported",
"bagtabs.gui.bag_namer.name": "New Name", "bagtabs.gui.bag_namer.name": "New Name",
"bagtabs.gui.bag_namer.placeholder": "Leave blank to clear", "bagtabs.gui.bag_namer.placeholder": "Leave blank to clear",
"bagtabs.gui.bag_namer.rename": "Rename", "bagtabs.gui.bag_namer.rename": "Rename",