//+------------------------------------------------------------------+ //| TJConnector.mq4 - Trading Journal connector for MetaTrader 4 | //| | //| Reads the account history and sends it to Trading Journal: | //| closed tickets once, open tickets whenever they change, and M1 | //| bars around your trades when the journal asks for them. | //| It never places, modifies or closes orders, needs no DLL and no | //| trading permission. CI rejects any trade call here | //| (scripts/check-connector-readonly.ts). | //| | //| Set Account History to "All History" so the terminal shows | //| every closed ticket. The API: PRD "Accounts", section 7. | //+------------------------------------------------------------------+ #property copyright "Trading Journal" #property link "https://trading-jornal-ashen.vercel.app" #property version "1.00" #property description "Sends your account history to Trading Journal." #property description "Read-only: never places, modifies or closes trades. No DLL." #property strict input string InpKey = ""; // Key from Trading Journal input string InpBaseUrl = "https://trading-jornal-ashen.vercel.app"; // Trading Journal address #define TJ_VERSION "1.00" #define TJ_TIMEOUT_MS 10000 #define TJ_DAY 86400 #define TJ_BAR_CHUNK 5000 #define TJ_MONEY 2 string g_key = ""; string g_base = ""; string g_instance = ""; bool g_started = false; // the server cursor is known long g_cursor = -1; // seconds (server clock) of the newest closed ticket sent int g_atCursor[]; // tickets already sent at exactly g_cursor int g_openTicket[]; // open tickets as last sent, and what they looked like string g_openSig[]; int g_batch = 500; int g_poll = 10; // seconds between checks long g_resync = -1; // ms from the server: send the history again from here int g_queued = 0; // tickets not sent yet int g_backoff = 0; // seconds, 5 -> 300 on errors datetime g_nextTry = 0; // no request before this (TimeLocal) datetime g_nextCheck = 0; datetime g_nextBeat = 0; datetime g_nextBars = 0; datetime g_lastSent = 0; datetime g_lastTc = 0; // TimeCurrent as last seen datetime g_tcMoved = 0; // TimeLocal when it last moved (a fresh tick); 0 = not yet string g_error = ""; // Bars windows the server asked for (heartbeat "bars_wanted"), server seconds. string g_barSym[]; long g_barFrom[]; long g_barTo[]; int g_barTries[]; //+------------------------------------------------------------------+ int OnInit() { g_key = CleanKey(InpKey); g_base = StringTrimLeft(StringTrimRight(InpBaseUrl)); while(StringLen(g_base) > 0 && StringGetCharacter(g_base, StringLen(g_base) - 1) == '/') g_base = StringSubstr(g_base, 0, StringLen(g_base) - 1); g_instance = InstanceId(); EventSetTimer(1); Show(); return(INIT_SUCCEEDED); } void OnDeinit(const int reason) { EventKillTimer(); Comment(""); } void OnTimer() { datetime tc = TimeCurrent(); if(tc != g_lastTc) { if(g_lastTc != 0) g_tcMoved = TimeLocal(); g_lastTc = tc; } if(g_key == "") { g_error = "Paste your key into InpKey (right click the chart > Expert Advisors > Properties > Inputs)"; Show(); return; } if(TimeLocal() < g_nextTry) { Show(); return; } if(!g_started) Start(); else if(g_resync >= 0) { long from = g_resync / 1000; g_resync = -1; SendFrom(from); } else { if(TimeLocal() >= g_nextCheck) { g_nextCheck = TimeLocal() + g_poll; SendNew(); } if(TimeLocal() >= g_nextTry && TimeLocal() >= g_nextBeat) Heartbeat(); else if(g_queued == 0 && ArraySize(g_barSym) > 0 && TimeLocal() >= g_nextTry && TimeLocal() >= g_nextBars) SendBars(); } Show(); } //+------------------------------------------------------------------+ //| Start: where the server is, then the history from there | //+------------------------------------------------------------------+ void Start() { string resp; if(Http("GET", "cursor", "", resp) != 200) return; g_batch = (int)MathMax(1, MathMin(1000, JsonLong(resp, "batch_size", 500))); g_poll = (int)MathMax(1, MathMin(60, JsonLong(resp, "poll_seconds", 10))); long from = JsonLong(resp, "from_time_msc", 0) / 1000; long resync = JsonLong(resp, "resync_from", -1); if(resync >= 0 && resync / 1000 < from) from = resync / 1000; g_started = true; g_error = ""; // The day of overlap only at the start (and on resync): the server ignores what it has. g_cursor = (long)MathMax(0, from - TJ_DAY) - 1; ArrayResize(g_atCursor, 0); ArrayResize(g_openTicket, 0); ArrayResize(g_openSig, 0); Heartbeat(); SendNew(); } // Send the history again from `from` (seconds, server clock), a day of overlap included. void SendFrom(const long from) { g_cursor = (long)MathMax(0, from - TJ_DAY) - 1; ArrayResize(g_atCursor, 0); ArrayResize(g_openTicket, 0); ArrayResize(g_openSig, 0); SendNew(); } //+------------------------------------------------------------------+ //| Tickets: buy and sell, balance and credit | //+------------------------------------------------------------------+ bool Kept(const int type) { return(type == OP_BUY || type == OP_SELL || type == 6 || type == 7); } bool IsTrade(const int type) { return(type == OP_BUY || type == OP_SELL); } // The time the server keeps a closed ticket under: its close, or the operation for balance. long TicketTime() { return(IsTrade(OrderType()) ? (long)OrderCloseTime() : (long)OrderOpenTime()); } string OpenSig() { return(Num(OrderLots(), 8) + "@" + Num(OrderOpenPrice(), 8)); } int OpenIndex(const int ticket) { for(int i = 0; i < ArraySize(g_openTicket); i++) if(g_openTicket[i] == ticket) return(i); return(-1); } // Closed tickets newer than the cursor, oldest first; then open tickets that are new or changed. void SendNew() { long keys[][2]; int total = OrdersHistoryTotal(); ArrayResize(keys, total); int n = 0; for(int i = 0; i < total; i++) { if(!OrderSelect(i, SELECT_BY_POS, MODE_HISTORY) || !Kept(OrderType())) continue; long t = TicketTime(); if(t < g_cursor || (t == g_cursor && SentAtCursor(OrderTicket()))) continue; keys[n][0] = t; keys[n][1] = OrderTicket(); n++; } ArrayResize(keys, n); if(n > 0) ArraySort(keys); int open[]; string sigs[]; int changed = OpenTickets(open, sigs); g_queued = n + changed; for(int b = 0; b < n; b += g_batch) { int e = MinInt(n, b + g_batch); string deals = ""; string symbols = ""; string seen = "|"; for(int k = b; k < e; k++) { if(!OrderSelect((int)keys[k][1], SELECT_BY_TICKET)) continue; AddDeal(deals, symbols, seen, false); } if(!Post(deals, symbols)) return; long last = keys[e - 1][0]; if(last != g_cursor) { g_cursor = last; ArrayResize(g_atCursor, 0); } for(int k = b; k < e; k++) if(keys[k][0] == g_cursor) AddAtCursor((int)keys[k][1]); g_queued = n - e + changed; g_lastSent = TimeLocal(); Show(); if(g_queued > 0 && TimeLocal() >= g_nextBeat) Heartbeat(); } if(changed > 0) { string deals = ""; string symbols = ""; string seen = "|"; int sent = 0; for(int i = 0; i < ArraySize(open); i++) { if(!OrderSelect(open[i], SELECT_BY_TICKET)) continue; int at = OpenIndex(open[i]); if(at >= 0 && g_openSig[at] == sigs[i]) continue; AddDeal(deals, symbols, seen, true); sent++; if(sent % g_batch == 0) { if(!Post(deals, symbols)) return; deals = ""; symbols = ""; seen = "|"; } } if(deals != "" && !Post(deals, symbols)) return; g_lastSent = TimeLocal(); } // What is open now, as sent; closed tickets drop out of the list. ArrayResize(g_openTicket, ArraySize(open)); ArrayResize(g_openSig, ArraySize(open)); for(int i = 0; i < ArraySize(open); i++) { g_openTicket[i] = open[i]; g_openSig[i] = sigs[i]; } g_queued = 0; } // Open buy and sell tickets now; returns how many are new or changed since last sent. int OpenTickets(int &open[], string &sigs[]) { int total = OrdersTotal(); ArrayResize(open, 0); ArrayResize(sigs, 0); int changed = 0; for(int i = 0; i < total; i++) { if(!OrderSelect(i, SELECT_BY_POS, MODE_TRADES) || !IsTrade(OrderType())) continue; int k = ArraySize(open); ArrayResize(open, k + 1); ArrayResize(sigs, k + 1); open[k] = OrderTicket(); sigs[k] = OpenSig(); int at = OpenIndex(open[k]); if(at < 0 || g_openSig[at] != sigs[k]) changed++; } return(changed); } void AddDeal(string &deals, string &symbols, string &seen, const bool isOpen) { string sym = OrderSymbol(); if(deals != "") StringAdd(deals, ","); StringAdd(deals, TicketJson(isOpen)); if(sym != "" && StringFind(seen, "|" + sym + "|") < 0) { seen += sym + "|"; string spec = SymbolJson(sym); if(spec != "") { if(symbols != "") StringAdd(symbols, ","); StringAdd(symbols, spec); } } } bool Post(const string deals, const string symbols) { long offset = (long)(TimeCurrent() - TimeGMT()); string body = "{\"platform\":\"mt4\",\"server_gmt_offset_sec\":" + IntegerToString(offset) + ",\"symbols\":[" + symbols + "],\"deals\":[" + deals + "]}"; string resp; int code = Http("POST", "deals", body, resp); return(code >= 200 && code < 300); } // The selected ticket in the API's form; an open one has no close and is provisional. string TicketJson(const bool isOpen) { int type = OrderType(); string sym = OrderSymbol(); int digits = PriceDigits(sym); string s = "{\"kind\":\"mt4_ticket\",\"ticket\":\"" + IntegerToString(OrderTicket()) + "\""; s += ",\"type\":\"" + TypeName(type) + "\""; s += ",\"lots\":\"" + Num(OrderLots(), 8) + "\""; s += ",\"symbol\":\"" + Esc(StringSubstr(sym, 0, 32)) + "\""; s += ",\"open_time\":" + IntegerToString((long)OrderOpenTime()); s += ",\"open_price\":\"" + Num(OrderOpenPrice(), digits) + "\""; if(!isOpen && IsTrade(type)) { s += ",\"close_time\":" + IntegerToString((long)OrderCloseTime()); s += ",\"close_price\":\"" + Num(OrderClosePrice(), digits) + "\""; } if(IsTrade(type)) s += ",\"sl\":\"" + Num(OrderStopLoss(), digits) + "\",\"tp\":\"" + Num(OrderTakeProfit(), digits) + "\""; s += ",\"commission\":\"" + Money(OrderCommission()) + "\""; s += ",\"swap\":\"" + Money(OrderSwap()) + "\""; s += ",\"profit\":\"" + Money(OrderProfit()) + "\""; if(OrderMagicNumber() > 0) s += ",\"magic\":\"" + IntegerToString(OrderMagicNumber()) + "\""; string comment = OrderComment(); if(comment != "") s += ",\"comment\":\"" + Esc(StringSubstr(comment, 0, 64)) + "\""; s += ",\"is_open\":" + (isOpen ? "true" : "false") + "}"; return(s); } string TypeName(const int type) { switch(type) { case OP_BUY: return("buy"); case OP_SELL: return("sell"); case 6: return("balance"); default: return("credit"); } } // Contract size, profit currency, digits and tick size: the journal's P&L uses the broker's own. string SymbolJson(const string sym) { double contract = 0, tick = 0; string currency = ""; long digits = 0; if(StringLen(sym) > 32 || !SymbolInfoDouble(sym, SYMBOL_TRADE_CONTRACT_SIZE, contract) || !SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_SIZE, tick) || !SymbolInfoString(sym, SYMBOL_CURRENCY_PROFIT, currency) || !SymbolInfoInteger(sym, SYMBOL_DIGITS, digits)) return(""); if(contract <= 0 || tick <= 0 || digits < 0 || digits > 10 || !IsCurrency(currency)) return(""); return("{\"symbol\":\"" + Esc(sym) + "\",\"contract_size\":\"" + Num(contract, 8) + "\",\"currency_profit\":\"" + currency + "\",\"digits\":" + IntegerToString(digits) + ",\"tick_size\":\"" + Num(tick, 10) + "\"}"); } //+------------------------------------------------------------------+ //| Heartbeat once a minute: alive, the server clock (its time zone), | //| what the history holds (the server compares it with its own) | //+------------------------------------------------------------------+ void Heartbeat() { g_nextBeat = TimeLocal() + 60; long total = 0; long from = 0; double profit = 0; int queued = 0; int n = OrdersHistoryTotal(); for(int i = 0; i < n; i++) { if(!OrderSelect(i, SELECT_BY_POS, MODE_HISTORY) || !Kept(OrderType())) continue; long open = (long)OrderOpenTime(); if(from == 0 || open < from) from = open; long t = TicketTime(); if(t > g_cursor || (t == g_cursor && !SentAtCursor(OrderTicket()))) queued++; double p = NormalizeDouble(OrderProfit(), TJ_MONEY); // The journal keeps every trade ticket and the non-zero balance operations. if(!IsTrade(OrderType()) && IsZeroMoney(p)) continue; total++; profit += p; } int m = OrdersTotal(); for(int i = 0; i < m; i++) { if(!OrderSelect(i, SELECT_BY_POS, MODE_TRADES) || !IsTrade(OrderType())) continue; long open = (long)OrderOpenTime(); if(from == 0 || open < from) from = open; total++; int at = OpenIndex(OrderTicket()); if(at < 0 || g_openSig[at] != OpenSig()) queued++; } if(from == 0) from = (long)TimeCurrent(); // TimeCurrent stands still on weekends and without a connection: no sample then. long age = 86400; if(g_tcMoved != 0) age = (long)MathMax(0, (long)(TimeLocal() - g_tcMoved)); string body = "{\"server_time\":" + IntegerToString((long)TimeCurrent()) + ",\"gmt_time\":" + IntegerToString((long)TimeGMT()) + ",\"terminal_build\":" + IntegerToString(TerminalInfoInteger(TERMINAL_BUILD)) + ",\"terminal_connected\":" + (TerminalInfoInteger(TERMINAL_CONNECTED) != 0 ? "true" : "false") + ",\"history_deals_total\":" + IntegerToString(total) + ",\"history_from\":" + IntegerToString(from) + ",\"history_profit_sum\":\"" + Money(profit) + "\"" + ",\"queued\":" + IntegerToString(queued) + ",\"tick_age_sec\":" + IntegerToString(age) + ",\"account_balance\":\"" + Money(AccountInfoDouble(ACCOUNT_BALANCE)) + "\"}"; string resp; if(Http("POST", "heartbeat", body, resp) != 200) return; g_poll = (int)MathMax(1, MathMin(60, JsonLong(resp, "poll_seconds", g_poll))); long resync = JsonLong(resp, "resync_from", -1); if(resync >= 0) g_resync = resync; QueueBars(resp); } //+------------------------------------------------------------------+ //| M1 bars around trades (the journal's trade chart and replay) | //+------------------------------------------------------------------+ void QueueBars(const string resp) { int p = StringFind(resp, "\"bars_wanted\""); if(p < 0) return; int end = StringFind(resp, "]", p); while(true) { int ob = StringFind(resp, "{", p); if(ob < 0 || (end >= 0 && ob > end)) break; int oe = StringFind(resp, "}", ob); if(oe < 0) break; string sym = ""; JsonRaw(resp, "symbol", ob, oe, sym); long from = JsonLong(resp, "from", -1, ob, oe); long to = JsonLong(resp, "to", -1, ob, oe); p = oe + 1; if(sym == "" || from < 0 || to < from || BarsQueued(sym, from, to)) continue; int k = ArraySize(g_barSym); ArrayResize(g_barSym, k + 1); ArrayResize(g_barFrom, k + 1); ArrayResize(g_barTo, k + 1); ArrayResize(g_barTries, k + 1); g_barSym[k] = sym; g_barFrom[k] = from; g_barTo[k] = to; g_barTries[k] = 0; } } bool BarsQueued(const string sym, const long from, const long to) { for(int i = 0; i < ArraySize(g_barSym); i++) if(g_barSym[i] == sym && g_barFrom[i] == from && g_barTo[i] == to) return(true); return(false); } void DropFirstBars() { int n = ArraySize(g_barSym); for(int i = 1; i < n; i++) { g_barSym[i - 1] = g_barSym[i]; g_barFrom[i - 1] = g_barFrom[i]; g_barTo[i - 1] = g_barTo[i]; g_barTries[i - 1] = g_barTries[i]; } ArrayResize(g_barSym, n - 1); ArrayResize(g_barFrom, n - 1); ArrayResize(g_barTo, n - 1); ArrayResize(g_barTries, n - 1); } // One window per call, in chunks; an empty answer marks the window's days as asked. void SendBars() { g_nextBars = TimeLocal() + 2; string want = g_barSym[0]; long from = g_barFrom[0]; long to = g_barTo[0]; string sym = TerminalSymbol(want); MqlRates r[]; int n = 0; if(sym != "") { ResetLastError(); n = CopyRates(sym, PERIOD_M1, (datetime)from, (datetime)to, r); if(n < 0) { // The terminal is still loading this history: come back later, a few times. g_barTries[0]++; if(g_barTries[0] < 5) { g_nextBars = TimeLocal() + 30; return; } n = 0; } } int digits = PriceDigits(sym); for(int c = 0; c == 0 || c < n; c += TJ_BAR_CHUNK) { int e = MinInt(n, c + TJ_BAR_CHUNK); string bars = ""; for(int i = c; i < e; i++) { double hi = MathMax(r[i].high, MathMax(r[i].open, r[i].close)); double lo = MathMin(r[i].low, MathMin(r[i].open, r[i].close)); if(i > c) StringAdd(bars, ","); StringAdd(bars, "{\"t\":" + IntegerToString((long)r[i].time) + ",\"o\":\"" + Num(r[i].open, digits) + "\",\"h\":\"" + Num(hi, digits) + "\",\"l\":\"" + Num(lo, digits) + "\",\"c\":\"" + Num(r[i].close, digits) + "\",\"v\":\"" + IntegerToString(r[i].tick_volume) + "\"}"); } string body = "{\"symbol\":\"" + Esc(want) + "\",\"timeframe\":\"M1\""; if(e >= n) body += ",\"from\":" + IntegerToString(from) + ",\"to\":" + IntegerToString(to); body += ",\"bars\":[" + bars + "]}"; string resp; int code = Http("POST", "bars", body, resp); if(code < 200 || code >= 300) return; if(e >= n) break; } DropFirstBars(); } // The terminal's name of a journal symbol: the journal keeps MT4 symbols upper case. string TerminalSymbol(const string want) { if(SymbolSelect(want, true)) return(want); int total = SymbolsTotal(false); for(int i = 0; i < total; i++) { string name = SymbolName(i, false); string upper = name; StringToUpper(upper); if(upper == want) { SymbolSelect(name, true); return(name); } } return(""); } //+------------------------------------------------------------------+ //| HTTP | //+------------------------------------------------------------------+ int Http(const string method, const string path, const string body, string &resp) { char data[]; char result[]; string resultHeaders; if(body != "") { StringToCharArray(body, data, 0, WHOLE_ARRAY, CP_UTF8); ArrayResize(data, ArraySize(data) - 1); } ResetLastError(); int code = WebRequest(method, g_base + "/api/ingest/v1/" + path, Headers(), TJ_TIMEOUT_MS, data, result, resultHeaders); resp = CharArrayToString(result, 0, WHOLE_ARRAY, CP_UTF8); if(code == -1) { int err = GetLastError(); if(err == 4060 || err == 4014) Fail("Add " + g_base + " in Tools > Options > Expert Advisors > Allow WebRequest for listed URL", 30); else Fail("No connection to " + g_base + " (error " + IntegerToString(err) + "), retrying", 0); return(-1); } if(code >= 200 && code < 300) { g_backoff = 0; g_error = ""; return(code); } string msg = ""; JsonRaw(resp, "message", 0, -1, msg); if(code == 401) Fail("Key revoked or wrong: paste the key from Trading Journal into InpKey", 300); else if(code == 409) Fail(msg != "" ? msg : "This key belongs to another account", 300); else if(code == 426) Fail("Update the connector: " + g_base + "/connect/metatrader", 3600); else if(code == 429) Fail("Waiting: too many requests", (int)RetryAfter(resultHeaders)); else Fail("Server answered " + IntegerToString(code) + (msg != "" ? ": " + msg : ""), 0); return(code); } string Headers() { return("Authorization: Bearer " + g_key + "\r\n" + "Content-Type: application/json\r\n" + "X-TJ-Login: " + IntegerToString(AccountInfoInteger(ACCOUNT_LOGIN)) + "\r\n" + "X-TJ-Server: " + Enc(AccountInfoString(ACCOUNT_SERVER)) + "\r\n" + "X-TJ-Company: " + Enc(AccountInfoString(ACCOUNT_COMPANY)) + "\r\n" + "X-TJ-Platform: mt4\r\n" + "X-TJ-Instance: " + g_instance + "\r\n" + "X-TJ-Version: " + TJ_VERSION + "\r\n"); } // Wait `wait` seconds before the next request; 0 = back off 5 s, 10 s ... 5 min. void Fail(const string text, int wait) { g_error = text; if(wait <= 0) { g_backoff = g_backoff == 0 ? 5 : MinInt(300, g_backoff * 2); wait = g_backoff; } g_nextTry = TimeLocal() + wait; } long RetryAfter(const string headers) { string h = headers; StringToLower(h); int p = StringFind(h, "retry-after:"); if(p < 0) return(60); string v = StringSubstr(h, p + 12, 12); int cut = StringFind(v, "\r"); if(cut >= 0) v = StringSubstr(v, 0, cut); v = StringTrimLeft(StringTrimRight(v)); return((long)MathMax(1, MathMin(3600, StringToInteger(v)))); } void Show() { string text = "TJ Connector " + TJ_VERSION + " | " + IntegerToString(AccountInfoInteger(ACCOUNT_LOGIN)) + " @ " + AccountInfoString(ACCOUNT_SERVER) + "\n"; if(g_error != "") text += g_error; else if(!g_started) text += "connecting..."; else text += (g_queued > 0 ? "sending history" : "online") + " | last sent " + (g_lastSent > 0 ? TimeToString(g_lastSent, TIME_MINUTES) : "-") + " | " + IntegerToString(g_queued) + " queued"; Comment(text); } //+------------------------------------------------------------------+ //| Small helpers | //+------------------------------------------------------------------+ // A random number kept by the terminal + this chart: two charts are two instances. string InstanceId() { string name = "TJC.instance"; if(!GlobalVariableCheck(name)) { MathSrand((int)GetTickCount()); GlobalVariableSet(name, (double)(MathRand() * 32768 + MathRand() + 1)); } return(IntegerToString((long)GlobalVariableGet(name)) + "-" + IntegerToString(ChartID())); } // Spaces and line breaks pasted with the key do not count. string CleanKey(const string s) { string out = ""; for(int i = 0; i < StringLen(s); i++) { ushort c = StringGetCharacter(s, i); if(c > 32 && c < 127) out += ShortToString(c); } return(out); } bool SentAtCursor(const int t) { for(int i = 0; i < ArraySize(g_atCursor); i++) if(g_atCursor[i] == t) return(true); return(false); } void AddAtCursor(const int t) { if(SentAtCursor(t)) return; int n = ArraySize(g_atCursor); ArrayResize(g_atCursor, n + 1); g_atCursor[n] = t; } int MinInt(const int a, const int b) { return(a < b ? a : b); } int PriceDigits(const string sym) { long d = 0; if(sym != "" && SymbolInfoInteger(sym, SYMBOL_DIGITS, d) && d >= 0 && d <= 10) return((int)d); return(8); } bool IsCurrency(const string s) { int n = StringLen(s); if(n < 3 || n > 4) return(false); for(int i = 0; i < n; i++) { ushort c = StringGetCharacter(s, i); if(c < 'A' || c > 'Z') return(false); } return(true); } bool IsZeroMoney(const double x) { return(MathAbs(x) < 0.5 / MathPow(10, TJ_MONEY)); } // Decimals as strings (the API takes no floats). string Num(const double x, const int digits) { if(!MathIsValidNumber(x)) return("0"); return(DoubleToString(x, digits)); } string Money(const double x) { return(Num(x, TJ_MONEY)); } string Esc(const string s) { string out = ""; for(int i = 0; i < StringLen(s); i++) { ushort c = StringGetCharacter(s, i); if(c == '"') out += "\\\""; else if(c == '\\') out += "\\\\"; else if(c < 32) out += StringFormat("\\u%04x", c); else out += ShortToString(c); } return(out); } // Header values in ASCII: other bytes (a company name in Cyrillic) as %XX of their UTF-8. string Enc(const string s) { uchar bytes[]; int n = StringToCharArray(s, bytes, 0, WHOLE_ARRAY, CP_UTF8) - 1; string out = ""; for(int i = 0; i < n; i++) { uchar b = bytes[i]; if(b < 32 || b > 126 || b == '%') out += StringFormat("%%%02X", b); else out += CharToString(b); } return(out); } // The raw value of "key" between `from` and `to` (-1 = the end): a number, true/false/null, // or the text of a string. bool JsonRaw(const string json, const string key, const int from, const int to, string &out) { int p = StringFind(json, "\"" + key + "\"", from); if(p < 0 || (to >= 0 && p >= to)) return(false); int n = StringLen(json); p += StringLen(key) + 2; while(p < n && StringGetCharacter(json, p) != ':') p++; p++; while(p < n && StringGetCharacter(json, p) == ' ') p++; if(p >= n) return(false); if(StringGetCharacter(json, p) == '"') { string s = ""; for(int i = p + 1; i < n; i++) { ushort c = StringGetCharacter(json, i); if(c == '\\' && i + 1 < n) { i++; s += ShortToString(StringGetCharacter(json, i)); continue; } if(c == '"') break; s += ShortToString(c); } out = s; return(true); } int e = p; while(e < n) { ushort c = StringGetCharacter(json, e); if(c == ',' || c == '}' || c == ']' || c == ' ') break; e++; } out = StringSubstr(json, p, e - p); return(true); } long JsonLong(const string json, const string key, const long fallback, const int from = 0, const int to = -1) { string v = ""; if(!JsonRaw(json, key, from, to, v) || v == "" || v == "null") return(fallback); return(StringToInteger(v)); } //+------------------------------------------------------------------+