From 3d02d28a8c90743a6a5a2227bf441cfd077768f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 00:35:19 +0000 Subject: [PATCH] library: reading levels (manual + CSV import), library CSV export, AR BookFind link with copy-title Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/README.md | 9 ++ .../quickadd/__pycache__/app.cpython-313.pyc | Bin 102983 -> 111723 bytes library/quickadd/app.py | 126 +++++++++++++++++- library/quickadd/index.html | 40 +++++- 4 files changed, 170 insertions(+), 5 deletions(-) diff --git a/library/README.md b/library/README.md index 4c0b5ae..227c702 100644 --- a/library/README.md +++ b/library/README.md @@ -234,3 +234,12 @@ The read-only `guest` login only sees the Library and book pages (no copy status **Print everyone's cards** (People) lays out a sheet of cards. - **More about a book**: a description and subjects from Open Library, plus look-up links (Goodreads, LibraryThing, WorldCat). **AR BookFind** is a link only: Renaissance's terms forbid automated access, so nothing is fetched from it. + +### Reading levels (AR) and spreadsheets + +AR BookFind has no public feed or import, and its terms forbid automated look-ups, so this site does not fetch from it. +Instead: the book page has an **Open AR BookFind** link (plus **Copy title** to paste into its search) and boxes to record the +**level, points and quiz number** you find there (the read-only guest sees them). **Library page -> Export / import** downloads +`library.csv` (one row per copy, with any reading level) and imports a CSV of `isbn, level, points[, quiz]` you made yourself +(for example from a spreadsheet or a Renaissance report you are entitled to export). Levels are stored in `reading-levels.json` +in the Add books data volume, keyed by ISBN, so they survive a Koha reinstall and are included in the usual backups of that folder. diff --git a/library/quickadd/__pycache__/app.cpython-313.pyc b/library/quickadd/__pycache__/app.cpython-313.pyc index 7ee1492cec76079e8847be327ff69854d6ecda56..345fff7c32fd10d63e1e73ae51912127cb6bf880 100644 GIT binary patch delta 14975 zcmZ`=30zdy)qm&B!Z0(yu^Iv^ZGdQ3PaJP^m-4&vj2gyAQI2az5Z4k3L~ zct|`U$l^(%A48{v!$?opJ(VgQ(_~2>1;$9Ps>7v`z}S*?O7W9v-Oq|Z3H!go!+te{ zNBmp`)NGJK560Dq&*pLB8R1c^;cN_-$_bCfal(uNwH<;1cbwxgz<~7E_DnebL~IAZaVc(V z6886;t?ghsfo&F#6WK@Pap{~m6`pc`Dt7a5Sh99C!c)?(x~k!*6tXQ7UUbjd_B()= zrGy;|;fAzf$G>7&0WV`-pSH!}w2PfeK`%+po!RiZ``FF_h@6lS-@{(;3*5RZ7cRK( z+4Ttz@41_Ip96S7O72O458S0acLV&!ePNGRWqOy?x{tDr5&4C5>c<6@A01NUD9=@H;7gU+gSa z;%rKLk%%6NFVODj6nEA)w~ONI6iIbu>cmfoA0+a;)Uz)UUXzaOOM+|em-p@E=YBx( zi;(yXs}@(4S5{TjuUWG~d>>;nc_r&t$UAA0o6TY#HS7lExgqu3e@1*7fS@2J{bYPZB`w_+Xq)QLP89pX9m!yZKuO3K^ z{5^$V#c;Q-b7zOW$=u=)Kb2-k=_b~jhe>jX$ahk`lnkFp@N|r>&F1K`wTis^sB}OD zUrD)g1$-=Rmn&V=0CAKEDI=aG?&nB+K@B#svAxM&-)Y-ocVwzW6D2WmH7Z|~X;5at zA1NE_De4!WURYCEwtR6-ea&Jqo06yl*bpcMEWtRRs-wLlQ!CyNT+pN<#n+@2{aK)Q z@9e)HDp*ZHl$k2_1vc6ojrP`7dlRZcHHkXZLQ&x|2A%lFDP!OZZ11R7#Kj=@%7?2! zdzdmjBOQC>XD}vJKYD*8H9>raVh<5vQ|fopwMUB#W0S)9k40#|pzs&&<&VwhO>9_L zKlmv2&ysw!Mp=4nw$VfrFG$}VoeeSW`~fpRgVlSMx`C?6REsX+7m%Dxq@TzwL>`vz zcsv6hlb(1y7vkOC#|Oa_Pdc(PA0Ua7(!<9KA;o>^_zHj%Qo@NCNRf(8WI~#|{)7|a zPLmL3QAYAJPXKb`^XT)FW*qLIQ*mfbrk%=4c#1@+P}B$q0gWq>3d%c8>Nu4I)7=M7 zeaNG9^G~zqKTU~0Q>0mg7E;7StTJ(?V$Hao?xG~N{+RYpNtb?_88{8oe9$5u!MT_W zx$cywm-0FjWdMxZ`b+}MasT9*bsFfG{^Xt-&FWxd)1_3Z43?Df+-{g7J^9>HI3j)d zTsl;`!+%lC!xZ<5!SkxPSy&!ZrRp?@b1-n47w1!pa-^*%V;5NF`T^ zRJ~Y*Ns&-3-TTt&nV*s7Hx1M^%GyM6!$jJM+>LR*0Ow|V>o&U+r<4BW;%OCBP7V^E zx~;`wXENd%id;a_n#T6lR;MWN(!YkIgAMi0&9)AEeM5Wu7O~cS;N=ZEr5||FBYkyY zL*hKjNM4prlqzvP6|A8en{oL&-kgFn@?`$cz@Pl&g+N8Ig@d{za9s6{aRt#oH+IF>EcV?r3s~ zOs5u#ONcZPu@iZLvbIyl)Dx+Y7G2DTc6aB+-)Iap5q+wrF1xr*TJ!!A*e*Tt{&cv* z{pS070$h=lJA%ku%2-8&4If*JZ1`CpOw0k1wU?${W}vcQZ?0czh0nmcsJ_+KU~d)c zDaJ&tu{U9p}PdK3u0;jiQ+$;%Cx|;Z)cowGSs{+#GH;iULXK z#Mz|dY9e=Fgg8fX4;N{bv6sFa&VzmK|q>N0D2r20I8;#V@RpLt0iDrLVJYtm`x=#6Ni7}Ubm4%7Es(RlJ(mB z=%4J-B+)j)u0=bzNu;H!uTq<i|X2y4n=Dh)&9bsa!9 zQ$H!4tsORQH_aWhbURSe7tp@VF5<%V=|sE3zTMX9%nTMSQq{MsVV5-UZF1xwNvXy5 z?M{(qr4KsA9n8E*fBDu3ueiVcHb^yXD+)1fe5#pwc_Qg4-h$+NDRX8cLJ{v87E zy7&M42Ebw21+W3$AN(yq3P7a%ZyuI_XK;xMzT-_y3)anS9r)yGEhNCla;p|{6Ms#m zv+k$0=rbTLv)h_>*0*GPqMQ-{E5j+#XK37PZ`@*U zVp+T7Jpm9ogJq?G%nVW{>s0E8xzv~MQ^n=Pd5D`MzZw9UhIfc(`0|MSbpXtQk-;n- z98iN=e#r5tp)egT z%V$GjiJ9z+FM!P|9C|i{rKk-yidoDZw6f)_Fqm%mkaU_wiVw@SFi3CM|qrqwzGQ2cSc2ZV}tWKT$z$>d3uB2s3vFj}N2SzN5w2=~Rr_ z72xDGagZC!tlH;PD~R*yqZbEA5x4wk93-Z&xww-`Wh*~G0cBs11k-Vv?2Cf}xIP#h z4{vKL{*ID9)r#sepQfy=x_mMAATy)n_{7hNFe68HjM+TI5mdI)E5!YkWImRYQlS?9 zB5zNHCFyTc6m!R=B;lhVYu(4h?Wdwf<*TXiKe2wbNi2c;Myy=}-ut%FWXu71V=!r$ZCK=kk{` zaFn(3f-Kl1&5sI%y1{3&U)CSaQ748sZOIcc2Or*a`B+J6kdRfCW;Hw;e)P!H=2`FFXfX11)$gD%LGcjv)s40-Y( z9z&n!!6C?(@5+Z<{9q_q9?FMKD8Ma$ylk6^Yj2{GS7t&`+7FdPg_NI=M_VgLFshwb z0Ldz?HCZ+xV<`4Z)Xb%02(=~HYL;sXpdLzC+buQ$>&#K@P&l+eBsiei1|6iLz-nB+6eG!3hYIADsn%((3Wl zq&u48qvd0>Atlm;5#nX6NxTQMi1#ANOqPddLsF(sS8o&RTib0-zJU4`XM-cZKzxHF zFA#Z?NDGk@vY{B}Yt>YeR$f^Q(?BP0D~2L4$xj! zRII@amrC@j^eRsv5Dn6=iqm+ac`U*c3&h8%h)>k7ni0?)F4{wFO)ZX1*{$|%_Eu-k z?ap?GTv`HAS+`-k4{=uPEahk6ipF^dj+1}x(U5H7q&-oQ0l*HZ3{ujXC|5dXfpoO0 z3NAqKl#hjoc!0%;OOc?l$RVD#Cxl8k|H(xZbuf_&)R)sSrH32Uh4mHe59ukrVla+` zgpVer45U2Le2Rah^}hN2u>Tg1I{yqH5oe-!BmN<}-u{G161F=aPkL>8hAN=6M*8da zq)IW8iYM|O6<>goJ=_?t(u9qMgdfCk8XM-(Hk<=qnrBes4@J(^&W%y1_zq3 zk&w`RfnzGV$7J7<}HZX%27gOhe0%T*x3(GwE^UP+BVycR=Z>J zZ1bt>9bIi=1}acp?__Evqv|jr*za5CL13x zdJ^ZH%O5tC44F!L^F8W{^M!I>7DNV9zp%E`*8Y$*bfQSAyKAP}ittUceUTWd@~ocP6$wQ9c2#w<`T4Nv77h?tk$Ax z!~+*WCj<(5^gDbZpUzp0s1o~;m$~VwipFCd8Vm35@TOoYVM)B&+A|tq!g)S1cvwfcbS)}E8tFFagdibU2+0Gf<44J^i1Ep7s$ypv zJ`FQDK5a*Pi=)%|z1F&sMwX4TPrbFPrCS8-#G8nrJ8K2mD6`GcWLEN+ZKB+rX0*3n00KyNJv=!PES$eP(+g_q{*Xex)K;PYK+*wtQW2r zOb;zN(lnBseqKEkxwyA<-;z&_K?fG^UEG(}*Lm5PFsd{4h2CfQCWzAqj|FqakiMAP z`e%DgX&ytGN1gUf2*%$Z@x7BU(`)IanzGrd*Jta?H378YocK*FoOJB3jo}+34siln z&*j)K_5}w|i%9udtC zqLK3r5FvT~s+0a%qI0d{SK`Dm;4*Xa4ozFQCA<&>BVI4*99oMO^WbXm+c*{u5llj; z5LSdurXPz77b1j6|8i$BQ9`s3d0S(w%i>?tn))LsL$5FTF+4`rJ z#0q+)B?6Q{$4k~Ir3wXWT4Vj82`dnUEshg-HMV%^M;zA{VTnNHV2~0{4^vCLB2q0OH(|CT%wj6JiAoR*n>kAu$welI2@< zuG8Gy(rPzvwmHp?cB;^+nAz??>qc94H<|B7|1i^+=5;jw-B^ZX0USbaj?*iFtd#}U z@Z@fr(5kL9NLL<-kY4vp$99SJe@mEXT} zBsyO1KG;2wBHj6cDcyBV!-YkUX*p8_gOiG<(vI&8$k_V=Y@*`0_=Z+rAf2MKAoDRk zolR6+6Q0`O2An*OPfNY*X!q&amwBu1>M9&4^W?EpM$`4*hOQHb&F1Vhw>#`OhI9Y> zAd;K^4~t5jAC7-FW+72Jzaau=;v`7F2KO(Rh(Bfp(crn(fQ2K&ho}n_0I$c8xE2Y4 zcJhH<#sPJ85r=V=z#9Y~>l-OKIn9zszvo-e1~p zJ6!Hba4kLG;7M4j^!;?~Jp}_~5l5cer&ETO-M}#~ewtzfoLgEt@E$VLB<50=^DXU) z`zF8cQ@7%2h)+u=Tz1i?6FNoQ@KjbS+mY!gAu@meH*B0sxi?}Cp8*2`hlu;$DWxRMFgk-7gd2Q(>oyF70 zNq}{$D-sJu?Eb^w!iCBtH%{(gaV6&alIC?3s{UOM_dn{eQA2R=_5-{2?&>e{hNKLK zOdY848m66!JC*4%6!w&l8bbP1vi_jHf9BD7hvyA+dgHUarfjbv2h#%dy{^K$clPDq zedjnJSUD6?J#|f<<_?dz-CMh3 zsQXR?CN5Y^_@sibcF}h=zk$HZF4#z+sEz!ds-8u?i$($h_jLC)9K3y?=Ggi(i_R`% zN}|N93LbCDBA1f1iP6BN{77V!M;+BueiO-zghuw+`WmE|-qO*qrM+dNVX=qP`WqgZ z>J3Z9P$Y$17?d^~FFzfAs^+KBPps_Ydly{^kLpi7JpB}ZT0dfn?$1AH>Af)rC-%wBjxm#jU=ZGC(juWs5?%rJ=5TwGUt5F@Z#Gp7F?SB_H2(}^=zp3F28Lw z$$Yf=;pTyLXZW*a=XiI8H);O4nsYW!(!$}SwdY-BZ&kcnadC-f&8^<@wRlhvwGMhq z_afvn^_A|e+*f`rk_(IbDvApXWuPmbKvx2SuKu}a)I+99kD=0|uKezLK4)A6-$aZ< zFERtK&d#ftuYSF#tavG?E`ok(NI)Y-Py9$mMb`Sp8@Ng*(3gV3K)UU|h&%*CypzvU zzz_nq_EbSdw!*>-YWmjmE73=j?RKaTt_4Wtp0r#=Lj8XvGziEPw1zn+V2D;a`}ZgW zRDpbeMc#S~L`fgrZQw0B8FoX&qFheUte&u0=pD6=Ks+}K#S++0!Ejb;3j0|mUnYbp^`{lOZXF zfStvdH=&n+ur7yJ>Iw~_k}F{8Bk1%D7DG)e9f3*zN{Q78VUA#n79+0B57*xWSW@WU z;#^^qfK7-c2zA5Dqj409twX3}QnW){!L+2K7v@T_FDxe18SMz^j6ifUEJAx>=JL2) zaD-YynO^7-u{2%H#0e&KoE)+nv}&g9>Ibq_VKs^D;4Y{c0+m>5!=N`#6IS~drA6{9 z8o;2%G~U4Y%MrAJofNbDa-1s6qLMGIf=HEL?9R)9M#R532=qfd{U52%UJfjrlxp%1 znhodPB=}_VJ25;44YPzO8id46%IS}b|G#nd2{+?%jsB9%IAkl)Gf)>IcSPZ=DT#Se5}E4++zsn2fr6Hy)W8aU(8F2rHm>{%GJ<1VHenMfY7ORe0M$RXrG*JH^LmH=4Vr^Fm!)h&!Q2%@xY9|Gxlfql#S>N2lRXO$|>rIT66ERJZNny~XREVUb0s9= zBV*`CQ?tCm*^hS(r1Zt#xBFOEugVjgt>j($T>7AE`p>7I+j6nMQ&R1nxyn;-AIfST zGHn_*wRyI*^=d{L_zsVdiw+j`M@c2a;TZ#qddo-fU`=~a`{4HePU+6!Xmq!y7I{s% zy`>)|r+dRIAMWZ;IR&R?oDM#ha&GwxIYZNy%B6?9hQcd{!&hHyxVWab96fj+jF>`Q z4BxCiYB_8fnElAc;l%t?;&8&8p@cc-rg#$;o=R!(sUbNO@S?gK1VYt3& zsJ_W#@AB4f8?L{@(|yNCLgLZIhZhfIdlT}86K0)RbPj%{c|mio)myxLxOnwN(IeCj z3w1+6ooBR?Z@n%tP*d=g7dsLq3px8 zwQpEGt83oa=-GIir=fAUVbf5|Sp}r)S&t;cdHyw(Y{Hf2SW5RT3E54$BDM ztl(GDY0nCNH9;QwU}OznOP}?89Z9ZmA$%Is@Ch4Hk`Jxtzfy5gan}NIU-Oj-kwS0R z{<-}suOWWK7&@Xe_QBr3UiI}9&JZ$|ipdA&@14JY!LY$RWH1lJdkxthbvEINpatLg zjJJGuJ%S6#`R=-&i&zFu+{tl=!j=cCe-XAkT>V;c@bVb-8zrHp<%!x0p<1LZ9Cscs z`34u1KoYxY+qeLVwQF(e7H9?^T>$rJG~xmb$yw!K<~M5Os&ZJWBY=!Ah1SYP%Aq+U zfR;}-Er@&~><$6TMf{Bkcz(w&cK$9;UkuZElZu`=$!&`vSlfi9EYJ+@Sq#r;z$fP| zhnOh33G%7C@M)>I7ju8c-uHbhZ(I&dGh;DLS=yRL+;55a%BPv4HW49}#7ner5$UkN+mM}wVJFsy=&@`@U~n)k?#8ob52ckoCJoDYB( zeDKlcpr3$FUSwBhm0ty+o$fZ;UicN)jL2<-Xwe0Lq7Bm@>a-J zE6+07lY(dEx%H55c#!nkP2^sATRr6A6M>WUcvp2!_SC~`nh)@N_mF(G38Dv|xefMf zLQ^=pcdWpo*{_I`&6^-CWtxyFOh-V2TMPQpWsWd|-R|sV87k$4&G1Xel2X#c<&aHy zou`%iY!DM<(F(c#Q<8Lf^=3@Tlbbg|ktW{)MZCOP4dL=pJKg?m!s|#u{(ckQvCfp2 zZN@vl0{QmM5Fap;RkUBqyDK(`w^|ex(BZG8N>9j1&3N;6TMLB4I=Qn2(o}_W)N&mb zsVVx8D$2o1rO%b?ZpR**E&t?pILQYjN%?ohgz(l4{$kNb7coe-^K1k)C3^7DEqL!J z&vAfJj%~wfJ5?@jgT&-z{v6g76I_*_t63$#)CvUw*+LGU{;1>yZICu8_gR=FSG7Vl zmAVRwRv-|d?BZu6c$_$v?+N0R%#`pX1y2w;N#qof({fY?%+vmi_^0Ky9WWn8 zU(uLRBxc0Gz?hw`t#d+nB^I{+(y3@X+GZ%yN%#E9EA|9BRF!$XZUe@mDMBTH? zTbv#3&K7z<*PUwX?6fs*Zo~L_>=?>nYqOW6jyIP|P3;a~^=uWzm$B*;c5}eah#G87 z-9asFwoUe2=eA8VcC@vMkE6bF!d8e08z9Y(A;ANtHKej2FWCxNuA>;$9V(zDXxwaW zwjq3Knroio%*++ru)~y9hNhSkAE5%Ok@IOPtI8MGDn03@u0=GC*O^3UUoRqbAMfIq zDRl`E_MVSDAbXpFDWt)>NOA-E6E7JSdBW4ZhIG6x4Vph1og&@Qr^a)#h!}b9!MXj7 z1BITD9Sqd-s@f{c*CLtSX3VXJ(8JM_vsc0l^zk{yr+t`||& z3*4-=YV(^V$!i0-w-OiW@$>eye5CKh%|!ZcguXVDdv{)G(Yhe+l5ysG4d+Q*WLlre zeUOun)N3x~@bjDES#|N;aG<_6Lp2;;s;f;=jl=^6Mp86HGn3ZoRUZWc(V&^@d6f@f z{Vn*>B(2X-`KAETOnqI9%2!YtR~N1t4FU{|hHHq%>o;VmMyJf$kfQo+JRtwu6b(}8 zsxOMyYo&vKjHAnd!Rl`K5cpb?Z0m*ylMu!%8V-#Rj$4!nIr9!|bELfT4%q67^6v^9 z>O4+}wvtO_zj_d2tVxqXvGf*1`C(d|HFZ)b-kLrslwi%66iT%E_e~t;OS0m&M)_eV z*=5a|6f|3NCWTV0d6Pn^R=QqiKg^hBEu0iex6&CS`(av`b>amGhNf8Q-IqTt!#e+F z$hv^@?|ZEa{n5hI9DXt2t)xaeRvaM@Aq>(?!;O8W9KRb5hA;BV1#q@9oUDNsyhm<> z$nfbCojjvDRLHUf)+`~ruHuAj`JcPdz2wM=F32@3^%qdHOkV4Pg4kRkPskSnb0(d- zC*$F>b%p$d3#MvjV%-JuCCrL%Q~vFOXxJ`C_ds?8vuwsKcs5<)>}yk!sW=*;!H7>F>nMM27F1xccN4#Cmy$j-0 zQ^iN*6?efjEdvKla`#=3Z2c!G$W!hZB6=c0MAC>%Cz4C#AtL=mjuLr{$oE9(ev=(S ziX#-fO5_hjuFHSE3)15VQlVo~5Ds3xZ|dAE{+;!TJne2s;fHzYug}HEjdw$=)`V9+ z&UE>%yW!)|lP$+uJOyRmjIzVA7bCqP)w1nJFxOSD>YMxMwlPi%iSi!F&TWjT;#^b)ybDjJh6t`NnQFWck^_Gnpxv)ri@W(#}HMln81nw`* zsf_1d3y2_^FlFg1?sXvk^{CPi{JfD-R)C+2aq&cFC0E9%-i`$1-;M_2V^qY)>sRoq zx2McmmZo|qZ6@;XMh6l#$1E>Vy<4I|9%nIw1^3cG@?sK>og8p4%!uoxk!N#>K~ClZ zpQpI<@``&QM!%JKB70=Vy^s-B0h+w8Yxs0cEhuOETjdk?!p0@vM;(5z*2M#-zOn^4^wL?Zp?FFUj}q zg~bpnf3O#p!Q*n`K3x4Liq@x%eKD_lbN%g#Fi| z-w^jlBG-tp|5^1NaTDu^UT3y-AW{(7rsd{>`hvu8k>M#sJn7H8{0~+cJ6a$IF18Ta zOr(|w^YQG(KlAZ7S0h`9v?P7HCR?Yyv!%^0{(^F_XPxXl=Tj6sON2c-c3mWv;Dz!K zm%YLL!P@;FN@Y0kI&t3+VXK{|PLC!MLqvHCM;wDA?2QsTC}Bq|^w>ywP;t{p2r2|X z;)|40PlWb?Vw3#xewc1LLwqX{+7^jh7;{E_!ty7_8tVO#HSw_SRzc+bI!9{}=O4us#3) delta 8418 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 R*zc$|W~xqmG$|bM{{W~9c|HID diff --git a/library/quickadd/app.py b/library/quickadd/app.py index 58aed23..7b7f4e2 100644 --- a/library/quickadd/app.py +++ b/library/quickadd/app.py @@ -1161,7 +1161,8 @@ def op_more(biblio_id): b = koha("GET", "/biblios/%d" % biblio_id) isbn = re.sub(r"[^0-9Xx]", "", (b.get("isbn") or "").split(" ")[0].split("|")[0]) title = (b.get("title") or "").rstrip(" /:") - out = {"links": [], "description": "", "subjects": [], "first_published": ""} + out = {"links": [], "description": "", "subjects": [], "first_published": "", "isbn": isbn, + "ar": _ar_load().get(_isbn13(isbn) or "", {})} q = urllib.parse.quote(title + " " + (b.get("author") or "")) if isbn: out["links"] = [ @@ -1211,6 +1212,117 @@ def op_more(biblio_id): return dict(out, **data) +# ------------------------------------------------ reading levels (AR) and CSV in/out +# AR BookFind has no public feed and its terms forbid automated access, so reading levels are kept +# here instead: typed in on the book page, or imported from a CSV you made yourself. Keyed by ISBN-13. +AR_FILE = os.path.join(DATA_DIR, "reading-levels.json") + + +def _isbn13(raw): + c = clean_code(raw) + if not c: + return None + return isbn10_to_13(c) if len(c) == 10 else c + + +def _ar_load(): + try: + return json.load(open(AR_FILE)) + except (OSError, ValueError): + return {} + + +def _ar_save(d): + tmp = AR_FILE + ".tmp" + with open(tmp, "w") as f: + json.dump(d, f) + os.replace(tmp, AR_FILE) + + +def _num(v, maxlen=8): + v = str(v or "").strip()[:maxlen] + return v if re.fullmatch(r"[0-9]{0,4}(\.[0-9]{1,2})?", v) else None + + +def op_ar_set(payload): + key = _isbn13(payload.get("isbn")) + level, points, quiz = _num(payload.get("level")), _num(payload.get("points")), re.sub(r"[^0-9]", "", str(payload.get("quiz") or ""))[:8] + if not key or level is None or points is None: + return 400, {"status": "invalid", "message": "Level and points are numbers like 4.5 and 6."} + with LOCK: + d = _ar_load() + if level or points or quiz: + d[key] = {"level": level, "points": points, "quiz": quiz} + else: + d.pop(key, None) + _ar_save(d) + return 200, {"status": "saved"} + + +def op_ar_import(payload): + """CSV with a header row; needs an isbn column and any of level, points, quiz.""" + import csv, io + text = str(payload.get("csv") or "")[:2_000_000] + rows = list(csv.reader(io.StringIO(text))) + if len(rows) < 2: + return 400, {"status": "invalid", "message": "That file has no rows."} + head = [re.sub(r"[^a-z0-9]", "", h.lower()) for h in rows[0]] + def col(*names): + for n in names: + if n in head: + return head.index(n) + return None + ci, cl, cp, cq = col("isbn", "isbn13"), col("level", "arlevel", "bl", "booklevel"), col("points", "arpoints", "pts"), col("quiz", "quizno", "quiznumber") + if ci is None or (cl is None and cp is None and cq is None): + return 400, {"status": "invalid", "message": "Need a header row with an isbn column and at least one of: level, points, quiz."} + done = skipped = 0 + with LOCK: + d = _ar_load() + for r in rows[1:]: + get = lambda i: r[i] if i is not None and i < len(r) else "" + key = _isbn13(get(ci)) + lv, pt = _num(get(cl)), _num(get(cp)) + if not key or lv is None or pt is None: + skipped += 1 + continue + old = d.get(key, {}) + d[key] = {"level": lv or old.get("level", ""), "points": pt or old.get("points", ""), + "quiz": re.sub(r"[^0-9]", "", get(cq))[:8] or old.get("quiz", "")} + done += 1 + _ar_save(d) + return 200, {"status": "imported", "count": done, "skipped": skipped} + + +def export_csv(): + """One row per copy: what you have, where it is, and any reading level on file.""" + import csv, io + where = {l["value"]: l["label"] for l in get_locations()} + colls = {c["value"]: c["label"] for c in get_collections()} + ar = _ar_load() + out = io.StringIO() + w = csv.writer(out) + w.writerow(["title", "author", "isbn", "year", "barcode", "call number", "shelf", "collection", "status", "ar level", "ar points", "ar quiz"]) + page = 1 + while True: + books = koha("GET", "/biblios", query={"_per_page": "200", "_page": str(page), "_order_by": "title"}) or [] + if not books: + break + ids = [b["biblio_id"] for b in books] + items = koha("GET", "/items", query={"q": json.dumps({"biblio_id": ids}), "_per_page": "2000"}) or [] + for b in books: + isbn = b.get("isbn") or "" + a = ar.get(_isbn13(isbn) or "", {}) + mine = [i for i in items if i["biblio_id"] == b["biblio_id"]] or [{}] + for i in mine: + w.writerow([(b.get("title") or "").rstrip(" /:"), b.get("author") or "", isbn, b.get("copyright_date") or "", + i.get("external_id") or "", i.get("callnumber") or "", where.get(i.get("location"), ""), + colls.get(i.get("collection_code"), ""), item_status(i) if i else "", a.get("level", ""), a.get("points", ""), a.get("quiz", "")]) + if len(books) < 200: + break + page += 1 + return out.getvalue().encode("utf-8-sig") + + # ------------------------------------------------------------ photo scanning MAX_IMAGE = 12 * 1024 * 1024 ZBAR_TIMEOUT = 20 @@ -1368,7 +1480,7 @@ class Handler(BaseHTTPRequestHandler): if self.path == "/healthz": return self._send(200, b"ok", "text/plain") path = self.path.split("?")[0] - if not self._guard(need_admin=path not in ("/", "/api/state", "/api/search", "/api/library") and not path.startswith("/api/book/")): + if not self._guard(need_admin=path not in ("/", "/api/state", "/api/search", "/api/library") and not path.startswith(("/api/book/", "/api/more/"))): return if path == "/": return self._send(200, INDEX, "text/html; charset=utf-8") @@ -1395,6 +1507,12 @@ class Handler(BaseHTTPRequestHandler): except KohaError as e: return self._send(404 if e.status == 404 else 502, {"status": "error", "message": "Koha is not answering yet - try again in a minute." if e.status == 503 else "Failed: %s" % e.body[:120]}) + if path == "/api/export.csv": + try: + body = export_csv() + except KohaError: + return self._send(502, {"status": "error"}) + return self._send(200, body, "text/csv; charset=utf-8", {"Content-Disposition": 'attachment; filename="library.csv"'}) if path == "/api/qr": try: svg = qr_svg((urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("t") or [""])[0]) @@ -1446,7 +1564,7 @@ class Handler(BaseHTTPRequestHandler): return self._send(415, {"status": "error", "message": "Please send a JPEG or PNG photo."}) return self._send(200, {"status": "ok", "codes": codes}) length = int(self.headers.get("Content-Length", "0")) - if length > 20000: + if length > (2_200_000 if self.path == "/api/ar/import" else 20000): return self._send(413, {"error": "too large"}) payload = json.loads(self.rfile.read(length) or b"{}") ops = {"/api/add": op_add, "/api/manual": op_manual, "/api/undo": op_undo, @@ -1454,7 +1572,7 @@ class Handler(BaseHTTPRequestHandler): "/api/barcodes/reserve": op_reserve, "/api/barcodes/assign": op_assign, "/api/item/update": op_item_update, "/api/item/delete": op_item_delete, "/api/item/copy": op_item_copy, "/api/book/delete": op_book_delete, - "/api/value/add": op_value_add, "/api/checkout": op_checkout, "/api/return": op_return, "/api/renew": op_renew, "/api/patron/add": op_patron_add} + "/api/value/add": op_value_add, "/api/ar/set": op_ar_set, "/api/ar/import": op_ar_import, "/api/checkout": op_checkout, "/api/return": op_return, "/api/renew": op_renew, "/api/patron/add": op_patron_add} fn = ops.get(self.path) if not fn: return self._send(404, {"error": "not found"}) diff --git a/library/quickadd/index.html b/library/quickadd/index.html index e3a3830..11ee74f 100644 --- a/library/quickadd/index.html +++ b/library/quickadd/index.html @@ -170,6 +170,16 @@
+
+ Export / import +
Export your books +
A spreadsheet (CSV) with one row per copy: title, author, ISBN, barcode, shelf, collection, status and any reading level on file.
+
+
Import reading levels +
A CSV with a header row and columns isbn, level, points (and optionally quiz). Existing levels for the same ISBN are updated.
+
+
+