2025-08-13 14:08:46 -07:00
|
|
|
import argparse
|
|
|
|
|
import json
|
|
|
|
|
import mimetypes
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import textwrap
|
|
|
|
|
import tomllib
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any, Sequence
|
|
|
|
|
|
|
|
|
|
import pytumblr2
|
|
|
|
|
|
|
|
|
|
from .effects import black, cyan
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def xdg_config_home() -> Path:
|
|
|
|
|
if xdg_path := os.environ.get("XDG_CONFIG_HOME"):
|
|
|
|
|
return Path(xdg_path)
|
|
|
|
|
else:
|
|
|
|
|
return Path("~").expanduser() / ".config"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DEFAULT_CONFIG_PATH: Path = xdg_config_home() / "tumblrcli" / "config.toml"
|
|
|
|
|
type Config = dict[str, Any]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_tumblr(config: Config) -> pytumblr2.TumblrRestClient:
|
|
|
|
|
return pytumblr2.TumblrRestClient(
|
|
|
|
|
config["consumer_key"],
|
|
|
|
|
config["consumer_secret"],
|
|
|
|
|
config["oauth_token"],
|
|
|
|
|
config["oauth_secret"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
|
|
|
class AppendOrdered(argparse.Action):
|
|
|
|
|
"""
|
|
|
|
|
Simple Action class to append interleaved commands (image, text, etc)
|
|
|
|
|
in the order that they appear.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __call__(
|
|
|
|
|
self,
|
|
|
|
|
parser: argparse.ArgumentParser,
|
|
|
|
|
namespace: argparse.Namespace,
|
|
|
|
|
values: Any,
|
|
|
|
|
option_string: str | None = None,
|
|
|
|
|
):
|
|
|
|
|
seq = getattr(namespace, "sequence", None)
|
|
|
|
|
if seq is None:
|
|
|
|
|
seq = []
|
|
|
|
|
seq.append((self.dest, values[0]))
|
|
|
|
|
setattr(namespace, "sequence", seq)
|
|
|
|
|
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
prog="tcli",
|
|
|
|
|
description="A command line interface that allows you to post to Tumblr",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"-c",
|
|
|
|
|
"--config",
|
|
|
|
|
default=DEFAULT_CONFIG_PATH,
|
|
|
|
|
type=Path,
|
|
|
|
|
help=f"The configuration file to use. Default: {DEFAULT_CONFIG_PATH}",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"-b",
|
|
|
|
|
"--blog",
|
|
|
|
|
type=str,
|
|
|
|
|
default=None,
|
|
|
|
|
help="The blog to post to. Overrides setting in configuration",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"-u",
|
|
|
|
|
"--unattended",
|
|
|
|
|
action="store_true",
|
|
|
|
|
default=False,
|
|
|
|
|
help="Make a post without confirmation",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"-d",
|
|
|
|
|
"--dry-fire",
|
|
|
|
|
action="store_true",
|
|
|
|
|
default=False,
|
|
|
|
|
help="Don't actually make the post",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
subparsers = parser.add_subparsers(
|
|
|
|
|
help="Post to make on Tumblr", required=True, dest="command"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
post = subparsers.add_parser("post", help="Make or reblog a post on Tumblr")
|
|
|
|
|
post.add_argument(
|
|
|
|
|
"-g",
|
|
|
|
|
"--tag",
|
|
|
|
|
nargs=1,
|
|
|
|
|
type=str,
|
|
|
|
|
action="extend",
|
|
|
|
|
help="Add a tag that will be added to the post without the leading #. Any hashes will be stripped before sending.",
|
|
|
|
|
)
|
|
|
|
|
post.add_argument(
|
|
|
|
|
"-t",
|
|
|
|
|
"--text",
|
|
|
|
|
nargs=1,
|
|
|
|
|
type=str,
|
|
|
|
|
action=AppendOrdered,
|
|
|
|
|
help="Add a paragraph of text to the post. Use '@file.txt' to load the text from file.txt.",
|
|
|
|
|
)
|
|
|
|
|
post.add_argument(
|
|
|
|
|
"-i",
|
|
|
|
|
"--image",
|
|
|
|
|
nargs=1,
|
|
|
|
|
type=argparse.FileType("rb"),
|
|
|
|
|
action=AppendOrdered,
|
|
|
|
|
help="Add an image from this path to the post.",
|
|
|
|
|
)
|
|
|
|
|
post.add_argument(
|
|
|
|
|
"-r",
|
|
|
|
|
"--reblog",
|
|
|
|
|
metavar="ID",
|
|
|
|
|
type=int,
|
|
|
|
|
help="Reblog this post ID. Requires the -p/--parent option.",
|
|
|
|
|
)
|
|
|
|
|
post.add_argument(
|
|
|
|
|
"-p",
|
|
|
|
|
"--parent",
|
|
|
|
|
type=str,
|
|
|
|
|
help="Reblog from this blog. Required for the -r/--reblog option.",
|
|
|
|
|
)
|
|
|
|
|
return parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
args = parse_args()
|
|
|
|
|
|
|
|
|
|
if not hasattr(args, "sequence"):
|
|
|
|
|
setattr(args, "sequence", [])
|
|
|
|
|
|
|
|
|
|
# Ensure that the config path exists but only if it's the default
|
|
|
|
|
if not args.config.exists():
|
|
|
|
|
print(
|
|
|
|
|
f"ERROR: Configuration path {args.config} does not exist. Make sure to fill out the configuration before using the tool.",
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
|
|
|
|
|
config = tomllib.loads(args.config.read_text())
|
|
|
|
|
|
|
|
|
|
if args.blog:
|
|
|
|
|
config["blog"] = args.blog
|
|
|
|
|
|
|
|
|
|
if bool(args.parent) ^ bool(args.reblog):
|
|
|
|
|
print(
|
|
|
|
|
"ERROR: -r/--reblog and -p/--parent arguments are both required for reblogs.",
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
|
|
|
|
|
match args.command:
|
|
|
|
|
case "post":
|
|
|
|
|
do_post(
|
|
|
|
|
config,
|
|
|
|
|
args.sequence,
|
|
|
|
|
args.tag or [],
|
|
|
|
|
args.reblog,
|
|
|
|
|
args.parent,
|
|
|
|
|
args.unattended,
|
|
|
|
|
args.dry_fire,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def do_post(
|
|
|
|
|
config: Config,
|
|
|
|
|
sequence: Sequence[tuple[str, Any]],
|
|
|
|
|
tags: Sequence[str],
|
|
|
|
|
reblog: int | None,
|
|
|
|
|
reblog_parent: str | None,
|
|
|
|
|
unattended: bool,
|
|
|
|
|
dry_fire: bool,
|
|
|
|
|
) -> None:
|
|
|
|
|
tags = [tag.replace("#", "") for tag in tags if tag.replace("#", "")]
|
|
|
|
|
content: list[dict[str, Any]] = []
|
|
|
|
|
media_sources: dict[str, str] = {}
|
|
|
|
|
media_id = 0
|
|
|
|
|
|
|
|
|
|
for kind, value in sequence:
|
|
|
|
|
match kind:
|
|
|
|
|
case "text":
|
|
|
|
|
if value.startswith("@"):
|
|
|
|
|
filepath = Path(value[1:])
|
|
|
|
|
if not filepath.is_file():
|
|
|
|
|
print(
|
|
|
|
|
f"ERROR: {filepath} does not exist, exiting",
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
value = filepath.read_text()
|
|
|
|
|
for paragraph in value.split("\n\n"):
|
|
|
|
|
content += [{"type": "text", "text": paragraph.strip()}]
|
|
|
|
|
case "image":
|
|
|
|
|
filetype, _ = mimetypes.guess_type(value.name)
|
|
|
|
|
identifier = f"media-{media_id}"
|
|
|
|
|
content += [
|
|
|
|
|
{
|
|
|
|
|
"type": "image",
|
|
|
|
|
"media": {"type": filetype, "identifier": identifier},
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
media_sources[identifier] = value.name
|
|
|
|
|
media_id += 1
|
|
|
|
|
|
|
|
|
|
if not content and not reblog:
|
|
|
|
|
print("ERROR: no content supplied for this post.", file=sys.stderr)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
|
|
|
|
|
if not unattended:
|
|
|
|
|
print(
|
|
|
|
|
black("You are about to post this to Tumblr blog"),
|
|
|
|
|
cyan(config["blog"]) + ":",
|
|
|
|
|
)
|
|
|
|
|
print()
|
|
|
|
|
parts: list[str] = []
|
|
|
|
|
for block in content:
|
|
|
|
|
match block["type"]:
|
|
|
|
|
case "text":
|
|
|
|
|
for chunk in block["text"].split("\n\n"):
|
|
|
|
|
parts += [
|
|
|
|
|
"\n".join(
|
|
|
|
|
black(" > ") + line for line in textwrap.wrap(chunk)
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
case "image":
|
|
|
|
|
filename = media_sources[block["media"]["identifier"]]
|
|
|
|
|
filetype = block["media"]["type"]
|
|
|
|
|
parts += [black(f" > image ({filetype} {filename})")]
|
|
|
|
|
lines = f"\n{black(' > ')}\n".join(parts)
|
|
|
|
|
print(lines)
|
|
|
|
|
if tags:
|
|
|
|
|
print()
|
|
|
|
|
print("Tags: ", ", ".join([black(f"#{tag}") for tag in tags]))
|
|
|
|
|
print()
|
|
|
|
|
ask = True and not dry_fire
|
|
|
|
|
while ask:
|
|
|
|
|
try:
|
|
|
|
|
response = input("Does this look okay? [y/n]: ")
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
if response.lower() in ("y", "yes"):
|
|
|
|
|
ask = False
|
|
|
|
|
elif response.lower() in ("n", "no"):
|
|
|
|
|
print("Confirmation failed, not posting")
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
|
|
|
|
|
# end unattended
|
|
|
|
|
|
|
|
|
|
tumblr = get_tumblr(config)
|
|
|
|
|
|
|
|
|
|
if dry_fire:
|
|
|
|
|
print("Dry firing, not sending text post.")
|
|
|
|
|
else:
|
|
|
|
|
if reblog:
|
|
|
|
|
response = tumblr.reblog_post(
|
|
|
|
|
config["blog"],
|
|
|
|
|
parent_blogname=reblog_parent,
|
|
|
|
|
id=reblog,
|
|
|
|
|
content=content,
|
2025-08-18 09:34:52 -07:00
|
|
|
tags=tags,
|
2025-08-13 14:08:46 -07:00
|
|
|
media_sources=media_sources,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
response = tumblr.create_post(
|
|
|
|
|
config["blog"],
|
|
|
|
|
content=content,
|
2025-08-18 09:34:52 -07:00
|
|
|
tags=tags,
|
2025-08-13 14:08:46 -07:00
|
|
|
media_sources=media_sources,
|
|
|
|
|
)
|
|
|
|
|
print("Got response:")
|
|
|
|
|
print(json.dumps(response, indent=4))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
main()
|
|
|
|
|
except (SystemExit, KeyboardInterrupt):
|
|
|
|
|
print("Exiting")
|
|
|
|
|
raise
|