39d1691c14
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 lines
5.1 KiB
Python
154 lines
5.1 KiB
Python
#!/usr/bin/env python
|
|
"""Export IR's config (tags, artist assignments, tag associations, series pages)
|
|
as a single JSON file that FabledCurator's FC-5 ingest can read.
|
|
|
|
One-shot tool. Excludes:
|
|
- kind='artist' tags (FC stores artist as a row, not a tag — these become
|
|
image_artist_assignments keyed by sha256).
|
|
- kind='post' tags (FC has Post rows; scheduler refills going forward).
|
|
- archive_record, post_metadata, ML predictions/embeddings, suggestion data
|
|
(FC handles all of these via its own mechanisms — see FC-5 design doc).
|
|
|
|
All foreign keys collapsed to natural keys so the output is portable:
|
|
image_record.id -> image_record.hash (sha256); tag.id -> (tag.name, tag.kind).
|
|
|
|
Usage:
|
|
python scripts/export_for_fabledcurator.py > ir-export.json
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from datetime import UTC, datetime
|
|
|
|
# Bootstrap IR's app context so we can use its SQLAlchemy session.
|
|
sys.path.insert(0, ".")
|
|
from app import create_app, db # noqa: E402
|
|
from app.models import ImageRecord, SeriesPage, Tag, image_tags # noqa: E402
|
|
|
|
_SKIP_KINDS = frozenset({"artist", "post"})
|
|
|
|
|
|
def _export_tags():
|
|
"""All tags except artist + post kinds, with fandom_name resolved."""
|
|
out = []
|
|
for tag in Tag.query.all():
|
|
if tag.kind in _SKIP_KINDS:
|
|
continue
|
|
fandom_name = None
|
|
if tag.fandom_id:
|
|
f = Tag.query.get(tag.fandom_id)
|
|
if f is not None:
|
|
fandom_name = f.name
|
|
out.append({
|
|
"name": tag.name,
|
|
"kind": tag.kind or "general",
|
|
"fandom_name": fandom_name,
|
|
})
|
|
return out
|
|
|
|
|
|
def _export_image_artist_assignments():
|
|
"""For each image, find its artist:Name tag (if any) and emit
|
|
{sha256, artist_name}."""
|
|
out = []
|
|
artist_tag_ids = {
|
|
t.id: t.name for t in Tag.query.filter_by(kind="artist").all()
|
|
}
|
|
if not artist_tag_ids:
|
|
return out
|
|
# Join image_record + image_tags to find which images have artist tags.
|
|
q = (
|
|
db.session.query(ImageRecord.hash, Tag.name)
|
|
.join(image_tags, image_tags.c.image_id == ImageRecord.id)
|
|
.join(Tag, Tag.id == image_tags.c.tag_id)
|
|
.filter(Tag.kind == "artist")
|
|
)
|
|
for sha, artist_name in q.all():
|
|
out.append({"sha256": sha, "artist_name": artist_name})
|
|
return out
|
|
|
|
|
|
def _export_image_tag_associations():
|
|
"""For each (image, tag) where tag.kind not in _SKIP_KINDS, emit
|
|
{sha256, tag_name, tag_kind, fandom_name}."""
|
|
out = []
|
|
q = (
|
|
db.session.query(ImageRecord.hash, Tag.name, Tag.kind, Tag.fandom_id)
|
|
.join(image_tags, image_tags.c.image_id == ImageRecord.id)
|
|
.join(Tag, Tag.id == image_tags.c.tag_id)
|
|
.filter(~Tag.kind.in_(_SKIP_KINDS))
|
|
)
|
|
fandom_name_cache: dict[int, str] = {}
|
|
for sha, tag_name, tag_kind, fandom_id in q.all():
|
|
fandom_name = None
|
|
if fandom_id:
|
|
if fandom_id not in fandom_name_cache:
|
|
f = Tag.query.get(fandom_id)
|
|
fandom_name_cache[fandom_id] = f.name if f else None
|
|
fandom_name = fandom_name_cache[fandom_id]
|
|
out.append({
|
|
"sha256": sha,
|
|
"tag_name": tag_name,
|
|
"tag_kind": tag_kind or "general",
|
|
"fandom_name": fandom_name,
|
|
})
|
|
return out
|
|
|
|
|
|
def _export_series_pages():
|
|
"""For each SeriesPage, emit {sha256, series_tag_name, page_number}."""
|
|
out = []
|
|
q = (
|
|
db.session.query(ImageRecord.hash, Tag.name, SeriesPage.page_number)
|
|
.join(SeriesPage, SeriesPage.image_id == ImageRecord.id)
|
|
.join(Tag, Tag.id == SeriesPage.series_tag_id)
|
|
)
|
|
for sha, series_tag_name, page_number in q.all():
|
|
out.append({
|
|
"sha256": sha,
|
|
"series_tag_name": series_tag_name,
|
|
"page_number": page_number,
|
|
})
|
|
return out
|
|
|
|
|
|
def main() -> None:
|
|
import argparse
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--output",
|
|
default=None,
|
|
help="Write JSON to this path instead of stdout (use this when "
|
|
"running inside a Swarm service that writes to a bind-mount).",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
app = create_app()
|
|
with app.app_context():
|
|
export = {
|
|
"source_app": "imagerepo",
|
|
"schema_version": 1,
|
|
"exported_at": datetime.now(UTC).isoformat(),
|
|
"tags": _export_tags(),
|
|
"image_artist_assignments": _export_image_artist_assignments(),
|
|
"image_tag_associations": _export_image_tag_associations(),
|
|
"series_pages": _export_series_pages(),
|
|
}
|
|
|
|
if args.output:
|
|
from pathlib import Path
|
|
out_path = Path(args.output)
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
out_path.write_text(json.dumps(export, indent=2) + "\n")
|
|
print(f"wrote {out_path} ({len(export['tags'])} tags, "
|
|
f"{len(export['image_tag_associations'])} associations)",
|
|
file=sys.stderr)
|
|
else:
|
|
json.dump(export, sys.stdout, indent=2)
|
|
sys.stdout.write("\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|