先日までに viewvc が python3に未対応であることが分かりましたので、
GitHub - masamitsu-murase/subversion_ajax_view: Subversion server's extension to show Ajax view
を参考に、javascript による Subversion リポジトリブラウザを作成。
まだ、revisionやtag、branchの表示には対応していませんが

<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8"> <style type="text/css"> h1 { margin:5px 0; font-size:1.5em; font-family:"HG丸ゴシックM-PRO"; } table { border-collapse: collapse; } thead th{ position: sticky; top: 0; background-color:#b0e0e6; font-weight: normal; border : solid 1px #999; padding :2px; } td { border : solid 1px #999; padding :2px;} #items {width:800px; } #items tbody tr td:nth-child(1){ width: 80px; text-align:center; } #items tbody tr td:nth-child(3){ width:190px; text-align:center; } #items tbody tr td:nth-child(4){ width: 80px; text-align:center; } #log_msg {width:800px; } #log_msg tbody tr td:nth-child(1){ width: 80px; text-align:center; } #log_msg tbody tr td:nth-child(2){ width:190px; text-align:center; } #log_msg_file {width:800px; } #log_msg_file tbody tr td:nth-child(1){ width: 80px; text-align:center; } #log_msg_file tbody tr td:nth-child(2){ width: 150px; text-align:center;} #log_msg_file tbody tr td:nth-child(3){ width: 40px; text-align:center; } </style> </head> <body> <h1>Current Dir</h1> <div id="dir_path"></div> <template id="dir_elm"> <a href=""></a> </template> <template id="dir_sep"> <span>/</span> </template> <h1>Contents</h1> <table id="items"> <thead> <tr> <th>Revision</th> <th>Path</th> <th>Date</th> <th>Byte</th> </tr> </thead> <tbody> </tbody> </table> <template id="item_tr"> <tr> <td></td><td><a href=""></a></td><td></td><td></td> </tr> </template> <h1>Histories</h1> <table id="log_msg"> <thead> <tr> <th>Revision</th> <th>Date</th> <th>Message</th> </tr> </thead> <tbody> </tbody> </table> <template id="log_msg_tr"> <tr> <td></td><td></td><td></td> </tr> </template> <table id="log_msg_file"> <thead> <tr> <th>Revision</th> <th>Action</th> <th>Kind</th> <th>Path</th> </tr> </thead> <tbody> </tbody> </table> <template id="log_msg_file_tr"> <tr> <td></td><td></td><td></td><td></td> </tr> </template> <script src="view_svn.js"></script> </body> </html>
'use strict'; const BASE_PATH = "/svn/test1/"; // repository name class ViewSvn { constructor() {} init_window=async()=> { let req_param = this.parse_url(); let dir = req_param["dir"] || ""; let rev = req_param["rev"] || ""; let repo_summary = await this.get_repo_summary(dir,rev); rev = repo_summary["svn-youngest-rev"]; // show current dir let dirs = this.parse_dir( dir ); this.disp_dirs(dirs, rev ); let repo_histories = await this.get_repo_histories(dir,rev ); this.disp_repo_histories( repo_histories ); // show contents let items = await this.get_items(dir,rev); this.disp_items( items, dir ); } disp_dirs=( dirs, rev )=>{ let dirs_container = document.querySelector("#dir_path"); let a_tmpl = document.querySelector("#dir_elm"); let sep_tmpl = document.querySelector("#dir_sep"); let a_elm = a_tmpl.content.cloneNode(true).querySelector("a"); a_elm.textContent = "TOP"; a_elm.setAttribute("href", "." ); dirs_container.appendChild( a_elm ); let i = 0; for (let dir of dirs ) { i += 1; let a_elm = a_tmpl.content.cloneNode(true).querySelector("a"); a_elm.textContent = dir; a_elm.setAttribute("href", "?dir="+ dirs.slice(0,i).join("/") ); let sep_tmpl = document.querySelector("#dir_sep"); dirs_container.appendChild( sep_tmpl.content.cloneNode(true).querySelector("span") ); dirs_container.appendChild( a_elm ); } } parse_dir=( dir_full_path )=>{ if( dir_full_path==undefined ) return []; if( dir_full_path.slice(-1) =="/"){ dir_full_path = dir_full_path.slice(0, -1); } if( dir_full_path.length==0 ) return []; let dirs = dir_full_path.split("/"); return dirs; } disp_items=( items, dir )=>{ let tbody_elm = document.querySelector("#items tbody"); let tr_tmpl = document.querySelector("#item_tr"); const regex = new RegExp( "^"+dir ); for (let item of items ) { let tr_elm = tr_tmpl.content.cloneNode(true); let td = tr_elm.querySelectorAll("td"); let a_elm = tr_elm.querySelectorAll("a"); let href_info = this.parse_item_href( item["D:href"] ); if( href_info["path"].length == 0 || href_info["path"] == "/" ){ continue; } a_elm[0].textContent = decodeURI( href_info["path"] ).replace(regex, ''); if( a_elm[0].textContent.length==0){ continue; } if( href_info["path"].slice(-1) =="/"){ let req_url = "?dir="+href_info["path"]; a_elm[0].setAttribute("href", req_url); } else { let req_url = BASE_PATH+href_info["path"]; a_elm[0].setAttribute("href", req_url); } td[2].textContent = item["lp1:creationdate"].toLocaleString('ja-JP'); td[3].textContent = item["lp1:getcontentlength"]; td[0].textContent = item["lp1:version-name"]; tbody_elm.appendChild( tr_elm ); } } disp_repo_histories=( repo_histories )=>{ let msg_tbody_elm = document.querySelector("#log_msg tbody"); let msg_tr_tmpl = document.querySelector("#log_msg_tr"); let file_tbody_elm = document.querySelector("#log_msg_file tbody"); let file_tr_tmpl = document.querySelector("#log_msg_file_tr"); for (let history of repo_histories ) { let msg_tr_elm = msg_tr_tmpl.content.cloneNode(true); let msg_td = msg_tr_elm.querySelectorAll("td"); msg_td[0].textContent = history["D:version-name"]; msg_td[1].textContent = history["S:date"].toLocaleString('ja-JP'); msg_td[2].textContent = history["D:comment"]; msg_tbody_elm.appendChild(msg_tr_elm); for (let item of history["items"] ){ let file_tr_elm = file_tr_tmpl.content.cloneNode(true); let file_td = file_tr_elm.querySelectorAll("td"); file_td[0].textContent = history["D:version-name"]; file_td[1].textContent = item["tag_name"]; file_td[2].textContent = item["node-kind"]; file_td[3].textContent = item["node_value"]; file_tbody_elm.appendChild(file_tr_elm); } } } get_items=async(dir,rev)=>{ let req_url = BASE_PATH +"!svn/bc/"+rev+"/"+dir; let req_xml = [ '<?xml version="1.0" encoding="utf-8"?>', '<propfind xmlns="DAV:">', '<prop>', '<creator-displayname xmlns="DAV:"/>', '<creationdate xmlns="DAV:"/>', '<version-name xmlns="DAV:"/>', '<getcontentlength xmlns="DAV:"/>', '<resourcetype xmlns="DAV:"/>', '</prop>','</propfind>'].join(""); let response = await fetch(req_url,{method : 'PROPFIND', headers: {'Depth':1}, body : req_xml}); if (! response.ok ){ return []; } let items = []; const parser = new DOMParser(); let xml_data = parser.parseFromString(await response.text(),"text/xml"); for (let item_elm of xml_data.documentElement.childNodes ) { if( item_elm.childNodes.length == 0 ) { continue; } let item = {}; for (let propstat of item_elm.childNodes ) { if( propstat.tagName==undefined ){ continue; } if( propstat.tagName=="D:href" ){ item[propstat.tagName] = propstat.childNodes[0].nodeValue; continue; } for (let prop_elm of item_elm.getElementsByTagName("D:prop")) { for (let attr_elm of prop_elm.childNodes ) { if(attr_elm.childNodes[0]==undefined){ continue; } if( attr_elm.tagName=="lp1:creationdate" ){ item[attr_elm.tagName] = new Date( attr_elm.childNodes[0].nodeValue ); } else { item[attr_elm.tagName] = attr_elm.childNodes[0].nodeValue; } } } } items.push( item ); } return items; } get_repo_histories=async(dir,rev)=>{ let req_url = BASE_PATH +"!svn/bc/"+rev+"/"+dir; let req_xml = [ '<?xml version="1.0" encoding="utf-8"?>', '<S:log-report xmlns:S="svn:">', '<S:start-revision>', rev, '</S:start-revision>', '<S:end-revision>0</S:end-revision>', '<S:limit>20</S:limit>', '<S:discover-changed-paths />', '<S:revprop>svn:author</S:revprop>', '<S:revprop>svn:date</S:revprop>', '<S:revprop>svn:log</S:revprop>', '<S:path></S:path>', '</S:log-report>'].join(""); let response = await fetch(req_url,{method : 'REPORT', headers: {}, body : req_xml}); if (! response.ok ){ return []; } let histories = []; const parser = new DOMParser(); let xml_data = parser.parseFromString(await response.text(),"text/xml"); for (let log_item of xml_data.documentElement.childNodes ) { let history = {"items":[] }; for (let item_elm of log_item.childNodes ) { if( item_elm.tagName==undefined ){ continue; } if(item_elm.tagName=="S:date" ){ history[item_elm.tagName ] = new Date(item_elm.childNodes[0].nodeValue); continue; } if(item_elm.tagName=="D:version-name" || item_elm.tagName=="D:comment" ){ history[item_elm.tagName ] = item_elm.childNodes[0].nodeValue; continue; } let file_or_dir = {}; file_or_dir["tag_name"] = item_elm.tagName; file_or_dir["node-kind"] = item_elm.getAttribute("node-kind"); file_or_dir["node_value"] = item_elm.getAttribute("node-kind"); file_or_dir["node_value"] = item_elm.childNodes[0].nodeValue; history["items"].push(file_or_dir); } if( history["items"].length == 0){ continue; } histories.push(history); } return histories; } get_repo_summary=async(dir,rev)=>{ if(! rev){ rev="HEAD"; } let req_url = BASE_PATH +"!svn/bc/"+rev+"/"+dir; let req_xml = ['<?xml version="1.0" encoding="utf-8"?>', '<D:options xmlns:D="DAV:">', '<D:activity-collection-set/>', '</D:options>' ].join(""); let response = await fetch(req_url,{method : 'OPTIONS', headers: {}, body : req_xml}); if (! response.ok ){ return {}; } let ret_data = {}; for (let [key, value] of await response.headers) { ret_data[key] = value; } return ret_data; } parse_item_href=( url )=>{ let re_pat = [ BASE_PATH, "!svn/bc/([0-9]+)", "(?:branches/([^/]+))?", "(?:tags/([^/]+))?", "/(.*)" ].join("") const regex = new RegExp( re_pat,"i"); const match = regex.exec(url); if (! match) { return {"rev" :match[1], "branch":undefined, "tags" :undefined, "path" :match[4] }; } return {"rev" :match[1], "branch":match[2], "tags" :match[3], "path" :match[4] }; } parse_url=()=>{ let ret_data = {}; const url = new URL( location.href ); ret_data["dir"] = url.searchParams.get('dir'); ret_data["branch"] = url.searchParams.get('branch'); ret_data["tag"] = url.searchParams.get('tag'); ret_data["rev"] = url.searchParams.get('rev'); return ret_data; } } let view_svn = new ViewSvn(); view_svn.init_window();