diff --git a/api/openapi.json b/api/openapi.json index 0ddfe14..a11ccd3 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -8840,6 +8840,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -9545,6 +9557,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -10262,6 +10286,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -12046,6 +12082,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -12881,6 +12929,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -14283,6 +14343,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -17662,6 +17734,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -18349,6 +18433,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -19048,6 +19144,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -19803,6 +19911,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -20460,6 +20580,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -21117,6 +21249,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -21776,6 +21920,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -22562,6 +22718,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -23780,6 +23948,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -24355,6 +24535,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -25579,6 +25771,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -26188,6 +26392,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -26817,6 +27033,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -27155,6 +27383,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -28554,6 +28794,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -29371,6 +29623,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -31380,6 +31644,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -32433,6 +32709,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 0e80d6b..b31daa9 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -250,6 +250,10 @@ func openBrowser(url string) error { return cmd.Start() } +func callbackRedirectURI(addr net.Addr) string { + return fmt.Sprintf("http://%s/callback", addr.String()) +} + // Login runs the full OAuth2 Authorization Code + PKCE flow. // Returns a credential to store. func Login(server string) (*config.Credential, error) { @@ -274,8 +278,7 @@ func Login(server string) (*config.Credential, error) { if err != nil { return nil, err } - port := listener.Addr().(*net.TCPAddr).Port - redirectURI := fmt.Sprintf("http://localhost:%d/callback", port) + redirectURI := callbackRedirectURI(listener.Addr()) // Register client clientInfo, err := registerClient(regEndpoint, redirectURI) diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 30f16c5..ae0e23f 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -3,6 +3,9 @@ package auth import ( "crypto/rand" "crypto/rsa" + "net" + "net/url" + "strconv" "testing" "time" @@ -10,6 +13,30 @@ import ( "github.com/go-jose/go-jose/v4/jwt" ) +func TestCallbackRedirectURIMatchesLoopbackListener(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = listener.Close() + }) + + redirect, err := url.Parse(callbackRedirectURI(listener.Addr())) + if err != nil { + t.Fatal(err) + } + if redirect.Hostname() != "127.0.0.1" { + t.Fatalf("redirect hostname = %q, want 127.0.0.1", redirect.Hostname()) + } + if redirect.Port() != strconv.Itoa(listener.Addr().(*net.TCPAddr).Port) { + t.Fatalf("redirect port = %q, listener = %q", redirect.Port(), listener.Addr()) + } + if redirect.Path != "/callback" { + t.Fatalf("redirect path = %q, want /callback", redirect.Path) + } +} + func TestVerifiedTokenToCredentialUsesFallbacks(t *testing.T) { vt := &VerifiedToken{ AccessToken: "access", diff --git a/internal/cmd/course/course.go b/internal/cmd/course/course.go index 511fd9d..8701347 100644 --- a/internal/cmd/course/course.go +++ b/internal/cmd/course/course.go @@ -85,6 +85,9 @@ func runCourseList(cmd *cobra.Command, opts courseListOpts) error { } return tui.TableResult{Rows: list.Rows, Total: list.Total, Page: list.Page}, nil }, + OnSelect: func(row map[string]any) error { + return runCourseView(cmd, fmt.Sprint(row["jwId"])) + }, EmptyMessage: "No courses found. Try a broader search.", }) } @@ -126,7 +129,7 @@ func courseListColumns() []output.Column { {Header: "Code", Key: "code"}, {Header: "Name", Key: "namePrimary"}, {Header: "Level", Key: "educationLevel.name"}, - {Header: "ID", Key: "id"}, + {Header: "JW ID", Key: "jwId"}, } } @@ -161,46 +164,50 @@ func newCmdView() *cobra.Command { Short: "View course details", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - c, err := api.NewTypedClient(cmdutil.ServerFromCmd(cmd), false) - if err != nil { - return err - } - jwID, err := cmdutil.Int64PtrIfSet(args[0]) - if err != nil { - return err - } - data, err := api.ParseResponseRaw(c.GetCourse(api.Ctx(), *jwID)) - if err != nil { - return err - } - if output.IsJSON() { - return output.JSON(data) - } - m := cmdutil.AsMap(data) - output.KVWithTitle([]output.KVPair{ - {Key: "ID", Value: output.Resolve(m, "id")}, - {Key: "Code", Value: output.Resolve(m, "code")}, - {Key: "Name", Value: output.Resolve(m, "namePrimary")}, - {Key: "Name (EN)", Value: output.Resolve(m, "nameSecondary")}, - {Key: "Level", Value: output.Resolve(m, "educationLevel.name")}, - {Key: "Category", Value: output.Resolve(m, "category.name")}, - {Key: "Class type", Value: output.Resolve(m, "classType.name")}, - {Key: "Gradation", Value: output.Resolve(m, "gradation.name")}, - {Key: "Course type", Value: output.Resolve(m, "type.name")}, - }, "Course") - - if sections, ok := m["sections"].([]any); ok && len(sections) > 0 { - fmt.Println() - output.Bold(" Sections") - rows := cmdutil.RowsFromAny(sections) - output.Table(rows, []output.Column{ - {Header: "ID", Key: "id"}, - {Header: "Code", Key: "code"}, - {Header: "Semester", Key: "semester.name"}, - {Header: "Campus", Key: "campus.name"}, - }) - } - return nil + return runCourseView(cmd, args[0]) }, } } + +func runCourseView(cmd *cobra.Command, id string) error { + c, err := api.NewTypedClient(cmdutil.ServerFromCmd(cmd), false) + if err != nil { + return err + } + jwID, err := cmdutil.Int64PtrIfSet(id) + if err != nil { + return err + } + data, err := api.ParseResponseRaw(c.GetCourse(api.Ctx(), *jwID)) + if err != nil { + return err + } + if output.IsJSON() { + return output.JSON(data) + } + m := cmdutil.AsMap(data) + output.KVWithTitle([]output.KVPair{ + {Key: "ID", Value: output.Resolve(m, "id")}, + {Key: "Code", Value: output.Resolve(m, "code")}, + {Key: "Name", Value: output.Resolve(m, "namePrimary")}, + {Key: "Name (EN)", Value: output.Resolve(m, "nameSecondary")}, + {Key: "Level", Value: output.Resolve(m, "educationLevel.name")}, + {Key: "Category", Value: output.Resolve(m, "category.name")}, + {Key: "Class type", Value: output.Resolve(m, "classType.name")}, + {Key: "Gradation", Value: output.Resolve(m, "gradation.name")}, + {Key: "Course type", Value: output.Resolve(m, "type.name")}, + }, "Course") + + if sections, ok := m["sections"].([]any); ok && len(sections) > 0 { + fmt.Println() + output.Bold(" Sections") + rows := cmdutil.RowsFromAny(sections) + output.Table(rows, []output.Column{ + {Header: "ID", Key: "id"}, + {Header: "Code", Key: "code"}, + {Header: "Semester", Key: "semester.name"}, + {Header: "Campus", Key: "campus.name"}, + }) + } + return nil +} diff --git a/internal/cmd/course/course_test.go b/internal/cmd/course/course_test.go new file mode 100644 index 0000000..f16b0ef --- /dev/null +++ b/internal/cmd/course/course_test.go @@ -0,0 +1,11 @@ +package course + +import "testing" + +func TestCourseListUsesViewIdentifier(t *testing.T) { + columns := courseListColumns() + last := columns[len(columns)-1] + if last.Header != "JW ID" || last.Key != "jwId" { + t.Fatalf("last column = %#v, want JW ID backed by jwId", last) + } +} diff --git a/internal/cmd/section/section.go b/internal/cmd/section/section.go index 6ae2f8e..5de6809 100644 --- a/internal/cmd/section/section.go +++ b/internal/cmd/section/section.go @@ -107,6 +107,9 @@ func runSectionList(cmd *cobra.Command, opts sectionListOpts) error { } return tui.TableResult{Rows: list.Rows, Total: list.Total, Page: list.Page}, nil }, + OnSelect: func(row map[string]any) error { + return runSectionView(cmd, fmt.Sprint(row["jwId"])) + }, EmptyMessage: "No sections found. Try a broader search.", }) } @@ -156,7 +159,7 @@ func sectionListColumns() []output.Column { {Header: "Course", Key: "course.namePrimary"}, {Header: "Semester", Key: "semester.name"}, {Header: "Campus", Key: "campus.name"}, - {Header: "ID", Key: "id"}, + {Header: "JW ID", Key: "jwId"}, } } @@ -194,60 +197,64 @@ func newCmdView() *cobra.Command { Short: "View section details", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - c, err := api.NewTypedClient(cmdutil.ServerFromCmd(cmd), false) - if err != nil { - return err - } - jwID, err := cmdutil.Int64PtrIfSet(args[0]) - if err != nil { - return err - } - data, err := api.ParseResponseRaw(c.GetSection(api.Ctx(), *jwID)) - if err != nil { - return err - } - if output.IsJSON() { - return output.JSON(data) - } - m := cmdutil.AsMap(data) - output.KVWithTitle([]output.KVPair{ - {Key: "ID", Value: output.Resolve(m, "id")}, - {Key: "Code", Value: output.Resolve(m, "code")}, - {Key: "Course", Value: output.Resolve(m, "course.namePrimary")}, - {Key: "Semester", Value: output.Resolve(m, "semester.name")}, - {Key: "Campus", Value: output.Resolve(m, "campus.name")}, - }, "Section") + return runSectionView(cmd, args[0]) + }, + } +} - if teachers, ok := m["teachers"].([]any); ok && len(teachers) > 0 { - fmt.Println() - output.Bold(" Teachers") - rows := cmdutil.RowsFromAny(teachers) - output.Table(rows, []output.Column{ - {Header: "ID", Key: "id"}, - {Header: "Name", Key: "namePrimary"}, - {Header: "Name (EN)", Key: "nameSecondary"}, - {Header: "Department", Key: "department.name"}, - }) - } +func runSectionView(cmd *cobra.Command, id string) error { + c, err := api.NewTypedClient(cmdutil.ServerFromCmd(cmd), false) + if err != nil { + return err + } + jwID, err := cmdutil.Int64PtrIfSet(id) + if err != nil { + return err + } + data, err := api.ParseResponseRaw(c.GetSection(api.Ctx(), *jwID)) + if err != nil { + return err + } + if output.IsJSON() { + return output.JSON(data) + } + m := cmdutil.AsMap(data) + output.KVWithTitle([]output.KVPair{ + {Key: "ID", Value: output.Resolve(m, "id")}, + {Key: "Code", Value: output.Resolve(m, "code")}, + {Key: "Course", Value: output.Resolve(m, "course.namePrimary")}, + {Key: "Semester", Value: output.Resolve(m, "semester.name")}, + {Key: "Campus", Value: output.Resolve(m, "campus.name")}, + }, "Section") - if schedules, ok := m["schedules"].([]any); ok && len(schedules) > 0 { - fmt.Println() - output.Bold(" Schedules") - rows := cmdutil.RowsFromAny(schedules) - for _, row := range rows { - normalizeScheduleRow(row) - } - output.Table(rows, []output.Column{ - {Header: "ID", Key: "id"}, - {Header: "Day", Key: "weekday"}, - {Header: "Start", Key: "startTime"}, - {Header: "End", Key: "endTime"}, - {Header: "Place", Key: "customPlace"}, - }) - } - return nil - }, + if teachers, ok := m["teachers"].([]any); ok && len(teachers) > 0 { + fmt.Println() + output.Bold(" Teachers") + rows := cmdutil.RowsFromAny(teachers) + output.Table(rows, []output.Column{ + {Header: "ID", Key: "id"}, + {Header: "Name", Key: "namePrimary"}, + {Header: "Name (EN)", Key: "nameSecondary"}, + {Header: "Department", Key: "department.name"}, + }) + } + + if schedules, ok := m["schedules"].([]any); ok && len(schedules) > 0 { + fmt.Println() + output.Bold(" Schedules") + rows := cmdutil.RowsFromAny(schedules) + for _, row := range rows { + normalizeScheduleRow(row) + } + output.Table(rows, []output.Column{ + {Header: "ID", Key: "id"}, + {Header: "Day", Key: "weekday"}, + {Header: "Start", Key: "startTime"}, + {Header: "End", Key: "endTime"}, + {Header: "Place", Key: "customPlace"}, + }) } + return nil } func newCmdSchedules() *cobra.Command { diff --git a/internal/cmd/section/section_test.go b/internal/cmd/section/section_test.go index 0624475..83fd6dc 100644 --- a/internal/cmd/section/section_test.go +++ b/internal/cmd/section/section_test.go @@ -119,3 +119,11 @@ func TestNormalizeScheduleRow_FullRow(t *testing.T) { t.Errorf("endTime: got %v, want 10:40", got) } } + +func TestSectionListUsesViewIdentifier(t *testing.T) { + columns := sectionListColumns() + last := columns[len(columns)-1] + if last.Header != "JW ID" || last.Key != "jwId" { + t.Fatalf("last column = %#v, want JW ID backed by jwId", last) + } +} diff --git a/internal/cmd/teacher/teacher.go b/internal/cmd/teacher/teacher.go index 53b2944..b9ff33d 100644 --- a/internal/cmd/teacher/teacher.go +++ b/internal/cmd/teacher/teacher.go @@ -84,6 +84,9 @@ func runTeacherList(cmd *cobra.Command, opts teacherListOpts) error { } return tui.TableResult{Rows: list.Rows, Total: list.Total, Page: list.Page}, nil }, + OnSelect: func(row map[string]any) error { + return runTeacherView(cmd, fmt.Sprint(row["id"])) + }, EmptyMessage: "No teachers found. Try a broader search.", }) } @@ -152,43 +155,47 @@ func newCmdView() *cobra.Command { Short: "View teacher details", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - c, err := api.NewTypedClient(cmdutil.ServerFromCmd(cmd), false) - if err != nil { - return err - } - teacherID, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return fmt.Errorf("invalid teacher id %q: %w", args[0], err) - } - data, err := api.ParseResponseRaw(c.GetTeacher(api.Ctx(), teacherID)) - if err != nil { - return err - } - if output.IsJSON() { - return output.JSON(data) - } - m := cmdutil.AsMap(data) - output.KVWithTitle([]output.KVPair{ - {Key: "ID", Value: output.Resolve(m, "id")}, - {Key: "Code", Value: output.Resolve(m, "code")}, - {Key: "Name", Value: output.Resolve(m, "namePrimary")}, - {Key: "Name (EN)", Value: output.Resolve(m, "nameSecondary")}, - {Key: "Department", Value: output.Resolve(m, "department.name")}, - {Key: "Title", Value: output.Resolve(m, "title")}, - }, "Teacher") - - if sections, ok := m["sections"].([]any); ok && len(sections) > 0 { - fmt.Println() - output.Bold(" Sections") - rows := cmdutil.RowsFromAny(sections) - output.Table(rows, []output.Column{ - {Header: "ID", Key: "id"}, - {Header: "Code", Key: "code"}, - {Header: "Course", Key: "course.namePrimary"}, - {Header: "Semester", Key: "semester.name"}, - }) - } - return nil + return runTeacherView(cmd, args[0]) }, } } + +func runTeacherView(cmd *cobra.Command, id string) error { + c, err := api.NewTypedClient(cmdutil.ServerFromCmd(cmd), false) + if err != nil { + return err + } + teacherID, err := strconv.ParseInt(id, 10, 64) + if err != nil { + return fmt.Errorf("invalid teacher id %q: %w", id, err) + } + data, err := api.ParseResponseRaw(c.GetTeacher(api.Ctx(), teacherID)) + if err != nil { + return err + } + if output.IsJSON() { + return output.JSON(data) + } + m := cmdutil.AsMap(data) + output.KVWithTitle([]output.KVPair{ + {Key: "ID", Value: output.Resolve(m, "id")}, + {Key: "Code", Value: output.Resolve(m, "code")}, + {Key: "Name", Value: output.Resolve(m, "namePrimary")}, + {Key: "Name (EN)", Value: output.Resolve(m, "nameSecondary")}, + {Key: "Department", Value: output.Resolve(m, "department.name")}, + {Key: "Title", Value: output.Resolve(m, "title")}, + }, "Teacher") + + if sections, ok := m["sections"].([]any); ok && len(sections) > 0 { + fmt.Println() + output.Bold(" Sections") + rows := cmdutil.RowsFromAny(sections) + output.Table(rows, []output.Column{ + {Header: "ID", Key: "id"}, + {Header: "Code", Key: "code"}, + {Header: "Course", Key: "course.namePrimary"}, + {Header: "Semester", Key: "semester.name"}, + }) + } + return nil +} diff --git a/internal/openapi/client.gen.go b/internal/openapi/client.gen.go index 3cbc394..b1d8c7b 100644 --- a/internal/openapi/client.gen.go +++ b/internal/openapi/client.gen.go @@ -2361,6 +2361,7 @@ type CalendarSubscriptionAppendResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -2368,6 +2369,7 @@ type CalendarSubscriptionAppendResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -2514,6 +2516,7 @@ type CalendarSubscriptionBatchResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -2521,6 +2524,7 @@ type CalendarSubscriptionBatchResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -2643,6 +2647,7 @@ type CalendarSubscriptionBatchResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -2650,6 +2655,7 @@ type CalendarSubscriptionBatchResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -2786,6 +2792,7 @@ type CalendarSubscriptionCreateResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -2793,6 +2800,7 @@ type CalendarSubscriptionCreateResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -2913,6 +2921,7 @@ type CalendarSubscriptionImportResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -2920,6 +2929,7 @@ type CalendarSubscriptionImportResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -3034,6 +3044,7 @@ type CalendarSubscriptionImportResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -3041,6 +3052,7 @@ type CalendarSubscriptionImportResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -3156,6 +3168,7 @@ type CalendarSubscriptionImportResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -3163,6 +3176,7 @@ type CalendarSubscriptionImportResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -3285,6 +3299,7 @@ type CalendarSubscriptionImportResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -3292,6 +3307,7 @@ type CalendarSubscriptionImportResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -3433,6 +3449,7 @@ type CalendarSubscriptionQueryResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -3440,6 +3457,7 @@ type CalendarSubscriptionQueryResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -3575,6 +3593,7 @@ type CalendarSubscriptionRemoveResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -3582,6 +3601,7 @@ type CalendarSubscriptionRemoveResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -3958,6 +3978,7 @@ type CompactOverviewResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -3965,6 +3986,7 @@ type CompactOverviewResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -4053,6 +4075,7 @@ type CompactOverviewResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -4060,6 +4083,7 @@ type CompactOverviewResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -4187,6 +4211,7 @@ type CompactOverviewResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -4194,6 +4219,7 @@ type CompactOverviewResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -4336,6 +4362,7 @@ type CourseDetailSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -4343,6 +4370,7 @@ type CourseDetailSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -4468,6 +4496,7 @@ type CurrentCalendarSubscriptionResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -4475,6 +4504,7 @@ type CurrentCalendarSubscriptionResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -4952,6 +4982,7 @@ type HomeworkCreateResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -4959,6 +4990,7 @@ type HomeworkCreateResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -5103,6 +5135,7 @@ type HomeworkUpdateResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -5110,6 +5143,7 @@ type HomeworkUpdateResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -5225,6 +5259,7 @@ type HomeworksListResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -5232,6 +5267,7 @@ type HomeworksListResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -5386,6 +5422,7 @@ type MatchSectionCodesResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -5393,6 +5430,7 @@ type MatchSectionCodesResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -5753,6 +5791,7 @@ type PaginatedScheduleResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -5760,6 +5799,7 @@ type PaginatedScheduleResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -6057,11 +6097,12 @@ type SectionDetailSchema struct { NameCn string `json:"nameCn"` NameEn *string `json:"nameEn"` } `json:"openDepartment"` - OpenDepartmentId *int `json:"openDepartmentId"` - Period *int `json:"period"` - PeriodsPerWeek *int `json:"periodsPerWeek"` - PracticePeriods *float32 `json:"practicePeriods"` - Remark *string `json:"remark"` + OpenDepartmentId *int `json:"openDepartmentId"` + Period *int `json:"period"` + PeriodsPerWeek *int `json:"periodsPerWeek"` + PracticePeriods *float32 `json:"practicePeriods"` + Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomType *struct { Code string `json:"code"` Id int `json:"id"` @@ -6104,6 +6145,7 @@ type SectionDetailSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -6289,6 +6331,7 @@ type SubscribedHomeworksResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -6296,6 +6339,7 @@ type SubscribedHomeworksResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -6438,6 +6482,7 @@ type SubscribedSchedulesResponseSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -6445,6 +6490,7 @@ type SubscribedSchedulesResponseSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` @@ -6584,6 +6630,7 @@ type TeacherDetailSchema struct { PeriodsPerWeek *int `json:"periodsPerWeek"` PracticePeriods *float32 `json:"practicePeriods"` Remark *string `json:"remark"` + RetiredAt *time.Time `json:"retiredAt,omitempty"` RoomTypeId *int `json:"roomTypeId"` ScheduleJsonParams interface{} `json:"scheduleJsonParams"` ScheduleRemark *string `json:"scheduleRemark"` @@ -6591,6 +6638,7 @@ type TeacherDetailSchema struct { SelectedStdCount *int `json:"selectedStdCount"` Semester *SemesterSchema `json:"semester"` SemesterId *int `json:"semesterId"` + SourceLastSeenAt *time.Time `json:"sourceLastSeenAt,omitempty"` StdCount *int `json:"stdCount"` SuggestScheduleWeekInfo *string `json:"suggestScheduleWeekInfo"` SuggestScheduleWeeks interface{} `json:"suggestScheduleWeeks"` diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4eaac05..f93fcac 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -9,7 +9,9 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/fatih/color" "github.com/mattn/go-runewidth" + "github.com/muesli/termenv" ) type SearchForm struct { @@ -36,6 +38,7 @@ type SearchTable struct { Form SearchForm Columns []output.Column Fetch func(SearchResult) (TableResult, error) + OnSelect func(map[string]any) error EmptyMessage string } @@ -48,17 +51,18 @@ const ( ) type tableModel struct { - spec SearchTable - inputs []textinput.Model - focus int - err string - mode tableMode - query SearchResult - result TableResult - selected int - scroll int - width int - height int + spec SearchTable + inputs []textinput.Model + focus int + err string + mode tableMode + query SearchResult + result TableResult + selected int + selection map[string]any + scroll int + width int + height int } type tableFetchMsg struct { @@ -70,9 +74,29 @@ func RunSearchTable(spec SearchTable) error { if spec.Fetch == nil { return fmt.Errorf("missing TUI fetch function") } + restoreColorProfile := configureSearchTableColors() + defer restoreColorProfile() + model := newTableModel(spec) - _, err := tea.NewProgram(model, tea.WithAltScreen()).Run() - return err + finalModel, err := tea.NewProgram(model, tea.WithAltScreen()).Run() + if err != nil { + return err + } + if result, ok := finalModel.(tableModel); ok && result.selection != nil && spec.OnSelect != nil { + return spec.OnSelect(result.selection) + } + return nil +} + +func configureSearchTableColors() func() { + if !output.Current.NoColor && !color.NoColor { + return func() {} + } + previous := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.Ascii) + return func() { + lipgloss.SetColorProfile(previous) + } } func newTableModel(spec SearchTable) tableModel { @@ -191,7 +215,16 @@ func (m tableModel) updateResults(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c", "esc", "q": return m, tea.Quit - case "/", "e", "enter": + case "/", "e": + m.mode = tableModeSearch + m.err = "" + m = m.focusInput(0) + return m, nil + case "enter": + if m.spec.OnSelect != nil && len(m.result.Rows) > 0 { + m.selection = m.result.Rows[m.selected] + return m, tea.Quit + } m.mode = tableModeSearch m.err = "" m = m.focusInput(0) @@ -288,7 +321,7 @@ func (m tableModel) searchView() string { } body := lipgloss.JoinVertical(lipgloss.Left, parts...) - return formBoxStyle.Width(contentWidth).Render(body) + return renderFormBox(body, contentWidth) } func (m tableModel) loadingView() string { @@ -302,7 +335,7 @@ func (m tableModel) loadingView() string { "", mutedStyle.Render("esc quit"), ) - return formBoxStyle.Width(contentWidth).Render(body) + return renderFormBox(body, contentWidth) } func (m tableModel) resultsView() string { @@ -324,10 +357,10 @@ func (m tableModel) resultsView() string { mutedStyle.Width(contentWidth).Render(helpText), ) body := lipgloss.JoinVertical(lipgloss.Left, parts...) - return formBoxStyle.Width(contentWidth).Render(body) + return renderFormBox(body, contentWidth) } - parts = append(parts, mutedStyle.Render(resultSummary(m.result))) + parts = append(parts, mutedStyle.Width(contentWidth).Render(resultSummary(m.result))) if len(m.result.Rows) == 0 { msg := m.spec.EmptyMessage if msg == "" { @@ -350,7 +383,15 @@ func (m tableModel) resultsView() string { parts = append(parts, "", mutedStyle.Width(contentWidth).Render(helpText)) } body := lipgloss.JoinVertical(lipgloss.Left, parts...) - return formBoxStyle.Width(contentWidth).Render(body) + return renderFormBox(body, contentWidth) +} + +func renderFormBox(body string, width int) string { + lines := strings.Split(body, "\n") + for i, line := range lines { + lines[i] = line + strings.Repeat(" ", max(0, width-lipgloss.Width(line))) + } + return formBoxStyle.Render(strings.Join(lines, "\n")) } func (m tableModel) renderTable(width int) string { @@ -366,7 +407,7 @@ func (m tableModel) renderTable(width int) string { absolute := m.scroll + i line := tableLine(cols, row, colWidths, gap, width, false) if absolute == m.selected { - line = selectedRowStyle.Width(width).Render(line) + line = selectedRowStyle.Render(line) } lines = append(lines, line) } @@ -427,20 +468,40 @@ func (m tableModel) canPageNext() bool { func (m tableModel) resultsHelpText(compact bool) string { if compact { + if m.spec.OnSelect != nil && len(m.result.Rows) > 0 { + if m.canPageNext() || m.currentPage() > 1 { + return "enter open • p/n • / edit • esc" + } + return "enter open • ↑/↓ • / edit • esc" + } if m.canPageNext() || m.currentPage() > 1 { return "p/n page • / edit • esc" } return "↑/↓ • r • / edit • esc" } - parts := []string{"↑/↓ move", "r refresh"} - if m.currentPage() > 1 { + if m.spec.OnSelect == nil || len(m.result.Rows) == 0 { + parts := []string{"↑/↓ move", "r refresh"} + if m.currentPage() > 1 { + parts = append(parts, "p prev") + } + if m.canPageNext() { + parts = append(parts, "n next") + } + parts = append(parts, "/ edit search", "esc quit") + return strings.Join(parts, " • ") + } + parts := make([]string, 0, 6) + parts = append(parts, "enter open") + parts = append(parts, "↑/↓ move", "r refresh") + if m.currentPage() > 1 && m.canPageNext() { + parts = append(parts, "p/n page") + } else if m.currentPage() > 1 { parts = append(parts, "p prev") - } - if m.canPageNext() { + } else if m.canPageNext() { parts = append(parts, "n next") } - parts = append(parts, "/ edit search", "esc quit") - return strings.Join(parts, " • ") + parts = append(parts, "/ edit", "esc quit") + return strings.Join(parts, " • ") } func (m tableModel) focusNext() tableModel { @@ -477,8 +538,6 @@ func (m tableModel) resultFromInputs() (SearchResult, error) { }, nil } - - func (m tableModel) searchContentWidth() int { return contentWidth(m.width, maxFormOuterWidth) } @@ -615,7 +674,6 @@ func tableCell(row map[string]any, col output.Column) string { return output.FormatCellPlain(output.Resolve(row, col.Key)) } - func padCell(value string, width int) string { if runewidth.StringWidth(value) > width { value = truncateCell(value, width) diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 9811f57..8f92c1f 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -7,6 +7,8 @@ import ( "github.com/Life-USTC/CLI/internal/output" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/fatih/color" + "github.com/muesli/termenv" ) func TestSearchResultFromInputs(t *testing.T) { @@ -136,6 +138,53 @@ func TestSearchTableEditReturnsToSearchWithoutQuitting(t *testing.T) { } } +func TestSearchTableEnterSelectsHighlightedRow(t *testing.T) { + m := newTableModel(SearchTable{ + OnSelect: func(map[string]any) error { return nil }, + }) + m.mode = tableModeResults + m.selected = 1 + m.result.Rows = []map[string]any{ + {"jwId": float64(101)}, + {"jwId": float64(202)}, + } + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(tableModel) + if cmd == nil { + t.Fatal("enter should quit the TUI before opening the selected result") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatalf("enter command = %T, want tea.QuitMsg", cmd()) + } + if got := m.selection["jwId"]; got != float64(202) { + t.Fatalf("selected jwId = %v, want 202", got) + } +} + +func TestSearchTableNoColorUsesAsciiProfile(t *testing.T) { + previousProfile := lipgloss.ColorProfile() + previousNoColor := output.Current.NoColor + previousFatihNoColor := color.NoColor + lipgloss.SetColorProfile(termenv.ANSI256) + output.Current.NoColor = true + color.NoColor = false + t.Cleanup(func() { + lipgloss.SetColorProfile(previousProfile) + output.Current.NoColor = previousNoColor + color.NoColor = previousFatihNoColor + }) + + restore := configureSearchTableColors() + if got := lipgloss.ColorProfile(); got != termenv.Ascii { + t.Fatalf("color profile = %v, want Ascii", got) + } + restore() + if got := lipgloss.ColorProfile(); got != termenv.ANSI256 { + t.Fatalf("restored color profile = %v, want ANSI256", got) + } +} + func TestSearchTableCanPageInsideResults(t *testing.T) { var got SearchResult m := newTableModel(SearchTable{ @@ -183,6 +232,7 @@ func TestSearchTableResultsFitCompactWindow(t *testing.T) { Fetch: func(SearchResult) (TableResult, error) { return TableResult{}, nil }, + OnSelect: func(map[string]any) error { return nil }, }) updated, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 12}) m = updated.(tableModel) @@ -208,6 +258,69 @@ func TestSearchTableResultsFitCompactWindow(t *testing.T) { if renderedHeight := lipgloss.Height(rendered); renderedHeight > 12 { t.Fatalf("rendered height = %d, want <= 12\n%s", renderedHeight, rendered) } + if !strings.Contains(rendered, "enter open") { + t.Fatalf("compact results did not show the selection action:\n%s", rendered) + } +} + +func TestSearchTableSelectedCJKRowDoesNotWrapWithColor(t *testing.T) { + previousProfile := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.ANSI256) + t.Cleanup(func() { + lipgloss.SetColorProfile(previousProfile) + }) + + m := newTableModel(SearchTable{ + Columns: []output.Column{ + {Header: "Code", Key: "code"}, + {Header: "Name", Key: "namePrimary"}, + {Header: "Level", Key: "educationLevel.name"}, + {Header: "JW ID", Key: "jwId"}, + }, + OnSelect: func(map[string]any) error { return nil }, + }) + m.mode = tableModeResults + m.result = TableResult{ + Rows: []map[string]any{ + { + "code": "MATH101", + "namePrimary": "数学分析(B1)", + "educationLevel": map[string]any{"name": "Undergraduate"}, + "jwId": "course-jw-id-1", + }, + { + "code": "MATH102", + "namePrimary": "线性代数(A)", + "educationLevel": map[string]any{"name": "Undergraduate"}, + "jwId": "course-jw-id-2", + }, + }, + Total: 2, + Page: 1, + } + + for _, size := range []struct { + width int + height int + }{ + {width: 80, height: 24}, + {width: 40, height: 12}, + } { + updated, _ := m.Update(tea.WindowSizeMsg{Width: size.width, Height: size.height}) + sized := updated.(tableModel) + selected := sized.View() + sized.selected = len(sized.result.Rows) + unselected := sized.View() + if selectedHeight, unselectedHeight := lipgloss.Height(selected), lipgloss.Height(unselected); selectedHeight != unselectedHeight { + t.Fatalf("%dx%d selected view height = %d, unselected view height = %d\nselected:\n%s\nunselected:\n%s", size.width, size.height, selectedHeight, unselectedHeight, selected, unselected) + } + if selectedHeight := lipgloss.Height(selected); selectedHeight > size.height { + t.Fatalf("%dx%d selected view height = %d\n%s", size.width, size.height, selectedHeight, selected) + } + if selectedWidth := lipgloss.Width(selected); selectedWidth > size.width { + t.Fatalf("%dx%d selected view width = %d\n%s", size.width, size.height, selectedWidth, selected) + } + } } func TestSearchTableErrorFitsCompactWindow(t *testing.T) { diff --git a/tui-screenshots/course-80x20.png b/tui-screenshots/course-80x20.png deleted file mode 100644 index 81c723d..0000000 Binary files a/tui-screenshots/course-80x20.png and /dev/null differ diff --git a/tui-screenshots/section-80x20.png b/tui-screenshots/section-80x20.png deleted file mode 100644 index f637cb5..0000000 Binary files a/tui-screenshots/section-80x20.png and /dev/null differ diff --git a/tui-screenshots/teacher-40x12-compact.png b/tui-screenshots/teacher-40x12-compact.png deleted file mode 100644 index 3815a5d..0000000 Binary files a/tui-screenshots/teacher-40x12-compact.png and /dev/null differ diff --git a/tui-screenshots/teacher-80x20.png b/tui-screenshots/teacher-80x20.png deleted file mode 100644 index 2c00f4f..0000000 Binary files a/tui-screenshots/teacher-80x20.png and /dev/null differ