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
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).
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.
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
run `gradlew --refresh-dependencies` to refresh the local cache. `gradlew clean` to reset everything
{this does not affect your code} and then start the process again.
- Adds a tab bar to supported inventory screens.
- Shows one tab for each carried bag or portable storage item the mod recognizes.
- 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:
============
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
## Included Content
Additional Resources:
==========
Community Documentation: https://docs.neoforged.net/
NeoForged Discord: https://discord.neoforged.net/
- A built-in Bag Tabs bag item.
- 27 inventory slots, like a single chest.
- Dyeable bag colors.
- 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.
mod_name=Bag Tabs
# 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/
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.
# This should match the base package used for the mod sources.
# 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) {
List<Component> lines = new ArrayList<>();
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"));
if (tab.pinned()) {
if (!DockConfigManager.isInteractionsLocked()) {

View File

@@ -5,6 +5,7 @@
"container.bagtabs.bag": "Bag",
"container.bagtabs.bag_namer": "Bag Namer",
"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.placeholder": "Leave blank to clear",
"bagtabs.gui.bag_namer.rename": "Rename",