diff --git a/server.py b/server.py index 6d80d16..405b2c5 100644 --- a/server.py +++ b/server.py @@ -134,20 +134,37 @@ def slug(s): # rsynced between six machines constantly — a tag in the name travels with the file and can't # drift out of sync, and it's visible in Finder. NSFW_RE = re.compile(r'-nsfw(?=[.\s(]|$)', re.I) +# -swim is the middle tier: swimwear/underwear/lingerie. Clothed, so it isn't +# nsfw, but a bikini body is still wrong for a shopfront NPC or a kids' scene, +# so consumers that ask for "dressed" shouldn't silently get one. +SWIM_RE = re.compile(r'-swim(?=[.\s(]|$)', re.I) def is_nsfw(path): return bool(NSFW_RE.search(os.path.basename(path))) -def tagged_name(name, nsfw): - """Add or remove the -nsfw suffix, preserving the extension.""" +def is_swim(path): + return bool(SWIM_RE.search(os.path.basename(path))) + + +def rating(path): + """Strictest tier that applies: nsfw > swim > safe.""" + return 'nsfw' if is_nsfw(path) else 'swim' if is_swim(path) else 'safe' + + +def tagged_name(name, nsfw, swim=False): + """Set the content-tier suffix, preserving the extension. Both tags are + stripped first, so this moves an asset between tiers rather than stacking + suffixes. nsfw wins if both are asked for.""" stem, dot, ext = name.rpartition('.') if not dot: stem, ext = name, '' - stem = NSFW_RE.sub('', stem).rstrip('-') + stem = SWIM_RE.sub('', NSFW_RE.sub('', stem)).rstrip('-') if nsfw: stem += '-nsfw' + elif swim: + stem += '-swim' return stem + (dot + ext if dot else '') @@ -174,6 +191,7 @@ def scan(): p = os.path.join(dd, f) row = {'name': f, 'path': p, 'kb': os.path.getsize(p) // 1024, 'home': os.path.basename(dd), 'nsfw': is_nsfw(f), + 'swim': is_swim(f), 'rating': rating(f), 'kind': 'img' if f.lower().endswith(IMG_EXT) else 'model'} if key == 'garments': row.update(manifest_for(p, f)) @@ -257,6 +275,8 @@ def manifest_for(path, name): rec.setdefault('layer', LAYER.get(rec.get('slot'), 30)) rec.setdefault('tags', []) rec['nsfw'] = is_nsfw(name) + rec['swim'] = is_swim(name) + rec['rating'] = rating(name) return rec @@ -293,6 +313,8 @@ def doll_catalogue(): it['tags'] = rec.get('tags', []) it['title'] = rec.get('title') or it['key'].replace('_', ' ') it['nsfw'] = is_nsfw(it['path']) + it['swim'] = is_swim(it['path']) + it['rating'] = rating(it['path']) it['tier'] = '2d' return cat @@ -649,7 +671,7 @@ class H(BaseHTTPRequestHandler): def fx(jid): out = os.path.join(DIRS['bodies'], tagged_name(slug(os.path.basename(p).rsplit('.', 1)[0]) + '-rigged.glb', - is_nsfw(p))) + is_nsfw(p), is_swim(p))) cmd = [MIRPAMO, 'rig', p, '-o', out] if body.get('tris'): cmd += ['--tris', str(int(body['tris']))] @@ -686,12 +708,13 @@ class H(BaseHTTPRequestHandler): save_manifest(m) return self.j({'ok': True, 'rec': rec}) - if u.path == '/api/tag': # add/remove the -nsfw suffix on an asset + if u.path == '/api/tag': # set the content tier on an asset p, nsfw = body.get('path', ''), bool(body.get('nsfw')) + swim = bool(body.get('swim')) if not allowed(p) or not os.path.isfile(p): return self.j({'error': 'path outside library'}, 403) d, name = os.path.split(p) - new = tagged_name(name, nsfw) + new = tagged_name(name, nsfw, swim) if new == name: return self.j({'ok': True, 'path': p, 'note': 'already tagged that way'}) tgt = os.path.join(d, new) @@ -699,7 +722,8 @@ class H(BaseHTTPRequestHandler): return self.j({'error': 'a file with that name already exists'}, 409) os.rename(p, tgt) # the convert cache is keyed on the source path, so a rename orphans its entry - return self.j({'ok': True, 'path': tgt, 'nsfw': nsfw}) + return self.j({'ok': True, 'path': tgt, 'nsfw': nsfw, + 'swim': swim and not nsfw, 'rating': rating(tgt)}) if u.path == '/api/outfit': # save a dress-up: body + attachments + clip + tints # The rigid-attach sliders used to live only in browser memory ("preview only") — every diff --git a/web/index.html b/web/index.html index a903a05..8067c48 100644 --- a/web/index.html +++ b/web/index.html @@ -49,6 +49,8 @@ .sw { width:22px; height:22px; border-radius:6px; border:1px solid var(--line); cursor:pointer; padding:0 } .sw.on { border-color:var(--gold); box-shadow:0 0 0 2px #e8c25744 } .fitchip { margin:3px 4px 0 0; font-weight:400 } + .swim { background:#1d5566; color:#cdf0ff; border-radius:4px; padding:0 4px; margin-right:5px; + font-size:10px; letter-spacing:.5px; } .nsfw { background:#7a2233; color:#ffd9e0; border-radius:4px; padding:0 4px; margin-right:5px; font-size:10px; letter-spacing:.5px; font-weight:700 } /* dressing-room grid — a wardrobe you browse by looking, not by reading filenames */ @@ -462,9 +464,10 @@ function rowHtml(it, kind) { ? `` : ''; // the -nsfw suffix is the fleet-wide convention (see ~/Documents/FBX); surfacing it here is // what makes "bases stay in the library" visible rather than a line in a README - const badge = it.nsfw ? 'NSFW' : ''; + const badge = it.nsfw ? 'NSFW' + : it.swim ? 'SWIM' : ''; return `
- ${tick}${it.name.replace(/-nsfw/i, '')} + ${tick}${it.name.replace(/-(nsfw|swim)/i, '')} ${badge}${it.kb}kb · ${it.home}
`; } function visible(rows) { @@ -516,8 +519,8 @@ function renderGarmentGrid() { const slot = g.slot ? `${g.slot}${g.slot_inferred ? '?' : ''}` : ''; return `
- ${slot}${g.nsfw ? 'NSFW' : ''} - ${(g.title || g.name).replace(/\.(glb|gltf|fbx|obj|png|jpg)$/i, '').replace(/-nsfw/i, '')} + ${slot}${g.nsfw ? 'NSFW' : g.swim ? 'SWIM' : ''} + ${(g.title || g.name).replace(/\.(glb|gltf|fbx|obj|png|jpg)$/i, '').replace(/-(nsfw|swim)/i, '')}
`; }).join('') || '
nothing matches — clear the filter or generate one →
'; @@ -684,11 +687,16 @@ $('bodyTypeSave').onclick = async () => { $('tagBtn').onclick = async () => { const it = BODY || GARM; if (!it) return toast('select a body or garment first'); - const now = /-nsfw/i.test(it.name); + // three tiers now, so the button cycles safe -> swim -> nsfw -> safe rather + // than toggling one flag + const cur = /-nsfw/i.test(it.name) ? 'nsfw' : /-swim/i.test(it.name) ? 'swim' : 'safe'; + const next = cur === 'safe' ? 'swim' : cur === 'swim' ? 'nsfw' : 'safe'; const r = await fetch('api/tag', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: it.path, nsfw: !now }) }).then(r => r.json()); + body: JSON.stringify({ path: it.path, nsfw: next === 'nsfw', swim: next === 'swim' }) }).then(r => r.json()); if (r.error) return toast('✗ ' + r.error); - toast(now ? 'untagged — this can now be exported to games' : 'tagged nsfw — blocked from game export'); + toast(next === 'safe' ? 'untagged — this can now be exported to games' + : next === 'swim' ? 'tagged swim — clothed, but flagged for shopfront/kids scenes' + : 'tagged nsfw — blocked from game export'); BODY = GARM = null; refresh(); };