#!/usr/bin/env python3 """Scoped Wayne Journal client. No external packages or OpenAI key required.""" import argparse import getpass import json import os import sys from datetime import datetime from pathlib import Path from urllib.error import HTTPError from urllib.parse import urlencode, quote from urllib.request import Request, urlopen from zoneinfo import ZoneInfo ORIGIN = 'https://wayneenterprisesinc.duckdns.org' DEFAULT_CONFIG = Path.home() / '.config' / 'wayne-journal' / 'credentials.json' def credential(): token = os.environ.get('WAYNE_JOURNAL_TOKEN', '').strip() if not token and DEFAULT_CONFIG.is_file(): token = json.loads(DEFAULT_CONFIG.read_text(encoding='utf-8'))['token'] return token def call(path, method='GET', body=None, key=None): headers = {'Accept': 'application/json', 'User-Agent': 'WayneJournalClient/1'} token = credential() if token: headers['Authorization'] = 'Bearer ' + token if method != 'GET': if not token: raise ValueError('Run configure or set WAYNE_JOURNAL_TOKEN first.') if not key: raise ValueError('Supply --request-id. Reuse it only to retry the same input.') headers.update({'Content-Type': 'application/json', 'Idempotency-Key': key}) request = Request(ORIGIN + '/api/v1' + path, data=json.dumps(body).encode() if body is not None else None, headers=headers, method=method) try: with urlopen(request, timeout=30) as response: return json.load(response) except HTTPError as error: detail = json.load(error).get('detail', 'Request failed') raise ValueError(f'{error.code}: {detail}') from None def main(): parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest='command', required=True) commands.add_parser('configure', help='Human enters a token privately; input is hidden.') commands.add_parser('day', help='Read today in Asia/Shanghai').add_argument('--date') posts = commands.add_parser('posts');posts.add_argument('--category', choices=['learning', 'projects', 'growth', 'notes', 'diary']);posts.add_argument('--offset', type=int, default=0) commands.add_parser('read').add_argument('id') task = commands.add_parser('add-task');task.add_argument('title');task.add_argument('--date');task.add_argument('--request-id', required=True) update = commands.add_parser('update-task');update.add_argument('id');update.add_argument('--revision', type=int, required=True);update.add_argument('--done', choices=['true', 'false']);update.add_argument('--date');update.add_argument('--title');update.add_argument('--archived', choices=['true', 'false']);update.add_argument('--request-id', required=True) diary = commands.add_parser('save-diary');diary.add_argument('file', help='Markdown file path');diary.add_argument('--date');diary.add_argument('--revision', type=int, required=True);diary.add_argument('--request-id', required=True) mood = commands.add_parser('save-mood');mood.add_argument('value', type=int, choices=range(1,6));mood.add_argument('--note', default='');mood.add_argument('--date');mood.add_argument('--revision', type=int, required=True);mood.add_argument('--request-id', required=True) draft = commands.add_parser('draft');draft.add_argument('title');draft.add_argument('file');draft.add_argument('--category', choices=['learning', 'projects', 'growth', 'notes'], default='notes');draft.add_argument('--request-id', required=True) args = parser.parse_args() if args.command == 'configure': if not sys.stdin.isatty(): raise ValueError('Configure requires a private interactive terminal.') token = getpass.getpass('Wayne token (hidden): ').strip() if not token.startswith('wj_') or len(token) < 30: raise ValueError('Invalid Wayne token format.') DEFAULT_CONFIG.parent.mkdir(mode=0o700, parents=True, exist_ok=True) descriptor = os.open(DEFAULT_CONFIG, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, 'O_NOFOLLOW', 0), 0o600) with os.fdopen(descriptor, 'w', encoding='utf-8') as file: json.dump({'token': token}, file) if os.name != 'nt': DEFAULT_CONFIG.chmod(0o600) print('Credential saved privately. No token was printed.') return day = getattr(args, 'date', None) or datetime.now(ZoneInfo('Asia/Shanghai')).date().isoformat() if args.command == 'day': output = call('/days/' + quote(day)) elif args.command == 'posts': output = call('/posts?' + urlencode({key: value for key, value in {'category': args.category, 'offset': args.offset}.items() if value is not None})) elif args.command == 'read': output = call('/posts/' + quote(args.id, safe='')) elif args.command == 'add-task': output = call('/tasks', 'POST', {'day': day, 'title': args.title}, args.request_id) elif args.command == 'update-task': patch = {'revision': args.revision} for key in ('title', 'done', 'archived'): value = getattr(args, key) if value is not None: patch[key] = value == 'true' if key in ('done','archived') else value if args.date: patch['day'] = args.date output = call('/tasks/' + quote(args.id, safe=''), 'PATCH', patch, args.request_id) elif args.command == 'save-diary': output = call('/days/' + quote(day) + '/diary', 'PUT', {'markdown': Path(args.file).read_text(encoding='utf-8'), 'revision': args.revision}, args.request_id) elif args.command == 'save-mood': output = call('/days/' + quote(day) + '/mood', 'PUT', {'value': args.value, 'note': args.note, 'revision': args.revision}, args.request_id) else: output = call('/drafts', 'POST', {'title': args.title, 'category': args.category, 'markdown': Path(args.file).read_text(encoding='utf-8')}, args.request_id) print(json.dumps(output, ensure_ascii=False, indent=2)) if __name__ == '__main__': try: main() except (ValueError, OSError, KeyError) as error: print(str(error), file=sys.stderr) sys.exit(1)