Something went wrong. Try again.
A fork of https://github.com/crosspoint-reader/crosspoint-reader
Something went wrong. Try again.
225 kB · 6187 lines
HTML
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672567356745675567656775678567956805681568256835684568556865687568856895690569156925693569456955696569756985699570057015702570357045705570657075708570957105711571257135714571557165717571857195720572157225723572457255726572757285729573057315732573357345735573657375738573957405741574257435744574557465747574857495750575157525753575457555756575757585759576057615762576357645765576657675768576957705771577257735774577557765777577857795780578157825783578457855786578757885789579057915792579357945795579657975798579958005801580258035804580558065807580858095810581158125813581458155816581758185819582058215822582358245825582658275828582958305831583258335834583558365837583858395840584158425843584458455846584758485849585058515852585358545855585658575858585958605861586258635864586558665867586858695870587158725873587458755876587758785879588058815882588358845885588658875888588958905891589258935894589558965897589858995900590159025903590459055906590759085909591059115912591359145915591659175918591959205921592259235924592559265927592859295930593159325933593459355936593759385939594059415942594359445945594659475948594959505951595259535954595559565957595859595960596159625963596459655966596759685969597059715972597359745975597659775978597959805981598259835984598559865987598859895990599159925993599459955996599759985999600060016002600360046005600660076008600960106011601260136014601560166017601860196020602160226023602460256026602760286029603060316032603360346035603660376038603960406041604260436044604560466047604860496050605160526053605460556056605760586059606060616062606360646065606660676068606960706071607260736074607560766077607860796080608160826083608460856086608760886089609060916092609360946095609660976098609961006101610261036104610561066107610861096110611161126113611461156116611761186119612061216122612361246125612661276128612961306131613261336134613561366137613861396140614161426143614461456146614761486149615061516152615361546155615661576158615961606161616261636164616561666167616861696170617161726173617461756176617761786179618061816182618361846185618661876188<!DOCTYPE html><html><head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Files - CrossPoint Reader</title> <script src="/js/jszip.min.js"></script> <style> :root { --font-color: #333; --bg: #f5f5f5; --title-color: #2c3e50; --card-bg: #FFF; --label-color: #7f8c8d; --border-color: #eee; --accent-color: rgb(110, 154, 130); --accent-color-10: rgba(110, 154, 130, 0.1); --accent-hover-color: #5a8c73; } @media (prefers-color-scheme: dark) { :root { --font-color: #f5f5f5; --bg: #333; --title-color: #ecf0f1; --card-bg: #444; --label-color: #bdc3c7; --border-color: #555; color-scheme: dark; } } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background-color: var(--bg); color: var(--font-color); } h1 { color: var(--title-color); border-bottom: 2px solid var(--accent-color); padding-bottom: 10px; } h2 { color: var(--title-color); margin: 0; } .card { background: var(--card-bg); border-radius: 8px; padding: 20px; margin: 15px 0; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .page-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; margin-bottom: 20px; } .page-header-left { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .breadcrumb-inline { color: var(--label-color); font-size: 1.1em; } .breadcrumb-inline a { color: var(--accent-color); text-decoration: none; } .breadcrumb-inline a:hover { text-decoration: underline; } .breadcrumb-inline .sep { margin: 0 6px; color: var(--label-color); } .breadcrumb-inline .current { color: var(--title-color); font-weight: 600; } .nav-links { margin: 20px 0; display: flex; gap: 10px; } .nav-links a { padding: 10px 20px; color: var(--font-color); text-decoration: none; border-radius: 4px; } .nav-links a.active { background-color: var(--accent-color); color: white; } .nav-links a:not(.active):hover { background-color: var(--accent-hover-color); color: white; } /* Action buttons */ .action-buttons { display: flex; gap: 10px; } .action-btn { color: white; padding: 10px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.95em; font-weight: 600; display: flex; align-items: center; gap: 6px; } .upload-action-btn { background-color: #27ae60; } .upload-action-btn:hover { background-color: #219a52; } .folder-action-btn { background-color: #f39c12; } .folder-action-btn:hover { background-color: #d68910; } .delete-action-btn { background-color: #e74c3c; } .delete-action-btn:hover { background-color: #c0392b; } /* Upload modal */ .modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); z-index: 200; justify-content: center; align-items: center; } .modal-overlay.open { display: flex; } .modal { background: var(--card-bg); border-radius: 8px; padding: 25px; max-width: 450px; width: 90%; max-height: 90vh; overflow-y: auto; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); } .modal.picker-mode { max-width: 920px; } .modal.image-preview-mode { max-width: 640px; } .image-preview-stage { display: flex; justify-content: center; align-items: center; overflow: auto; margin-bottom: 15px; border-radius: 6px; } .image-preview-stage img { max-width: 100%; max-height: 65vh; object-fit: contain; } #imagePreviewDownload { display: inline-block; width: auto; text-decoration: none; } .picker-columns.picker-active { display: flex; flex-direction: row; gap: 16px; align-items: flex-start; } .picker-columns.picker-active > .convert-options { flex: 0 0 340px; width: 340px; margin-bottom: 0; } .picker-columns.picker-active > .image-picker-section { flex: 1; margin-top: 0; max-height: 55vh; overflow-y: auto; } @media (max-width: 850px) { .modal.picker-mode { max-width: 95%; } .picker-columns.picker-active { flex-direction: column; } .picker-columns.picker-active > .convert-options { flex: 0 0 auto; width: 100%; } .picker-columns.picker-active > .image-picker-section { max-height: 40vh; } } .modal h3 { margin: 0 0 15px 0; color: var(--title-color); } .modal-close { float: right; background: none; border: none; font-size: 1.5em; cursor: pointer; color: var(--label-color); line-height: 1; transition: opacity 0.15s; } .modal-close:hover { color: var(--title-color); } .modal-close.disabled { opacity: 0.3; cursor: not-allowed; } .modal-close.disabled:hover { color: var(--label-color); } .file-table { width: 100%; border-collapse: collapse; } .file-table th, .file-table td { padding: 12px; text-align: left; border-bottom: 1px solid var(--border-color); } .file-table th { font-weight: 600; color: var(--label-color); } .file-table tr:hover { background-color: var(--accent-color-10); } .epub-badge { display: inline-block; padding: 2px 8px; background-color: #27ae60; color: white; border-radius: 10px; font-size: 0.75em; } .folder-badge { display: inline-block; padding: 2px 8px; background-color: #f39c12; color: white; border-radius: 10px; font-size: 0.75em; } .file-icon { margin-right: 8px; } .folder-link, .file-link { word-break: break-word; color: var(--accent-color); text-decoration: none; cursor: pointer; } .folder-link:hover, .file-link:hover { color: var(--accent-hover-color); text-decoration: underline; } .upload-form { margin-bottom: 10px; } .upload-form input[type="file"] { margin: 10px 0; width: 100%; } .upload-form input[type="file"]::file-selector-button { background-color: #27ae60; color: white; border: none; padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 0.9em; margin-right: 8px; } .upload-form input[type="file"].has-files::file-selector-button { background-color: #95a5a6; } .drop-zone { border: 2px dashed var(--border-color); border-radius: 6px; padding: 14px; text-align: center; cursor: pointer; transition: border-color 0.15s, background-color 0.15s; } .drop-zone.dragover { border-color: var(--accent-color); background-color: var(--accent-color-10); } .drop-zone-hint { margin: 8px 0 0; font-size: 0.85em; color: var(--label-color); pointer-events: none; } .drop-zone.dragover .drop-zone-hint { color: var(--accent-color); } .upload-btn { background-color: #27ae60; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 1em; width: 100%; margin-top: 12px; } .upload-btn:hover { background-color: #219a52; } .upload-btn.optimize { background-color: #9b59b6; } .upload-btn.optimize:hover { background-color: #8e44ad; } .upload-btn:disabled { background-color: #95a5a6; cursor: not-allowed; } /* Convert options */ .convert-options { margin-bottom: 10px; padding: 12px; border-radius: 6px; border: 1px solid var(--accent-color); } .convert-checkbox { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; } .convert-checkbox input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; outline: none; } .convert-checkbox input[type="checkbox"]:focus { outline: none; box-shadow: none; } .convert-warning { font-size: 0.85em; color: #d68910; background-color: rgba(214, 137, 16, 0.1); border: 1px solid #d68910; border-radius: 4px; padding: 8px 12px; margin-top: 8px; margin-bottom: 12px; line-height: 1.4; text-align: center; } .convert-info { font-size: 0.85em; color: var(--label-color); margin-top: 8px; line-height: 1.4; } .convert-settings { font-size: 0.8em; color: var(--label-color); margin-top: 6px; padding-left: 26px; display: flex; flex-wrap: wrap; gap: 16px; } .convert-settings span { display: inline-block; } /* Convert row - compact layout with checkbox and advanced options toggle */ .convert-row { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; } .convert-separator { display: none; /* Hidden, using space-between instead */ } .advanced-options-toggle { display: flex; align-items: center; gap: 4px; font-size: 0.85em; color: var(--label-color); cursor: pointer; user-select: none; transition: color 0.2s; } .advanced-options-toggle:hover { color: var(--accent-color); } /* Disable hover when disabled (opacity < 1) */ .advanced-options-toggle[style*="opacity: 0.3"]:hover, .advanced-options-toggle[style*="opacity: 0.5"]:hover { color: var(--label-color); } .advanced-options-text { font-weight: 500; } /* Advanced Options */ .advanced-options-arrow { font-size: 0.7em; transition: transform 0.2s; } .advanced-options-arrow.expanded { transform: rotate(90deg); } /* Settings content - hidden by default, shown when expanded */ .advanced-settings-content { display: none; margin-top: 12px; padding-left: 4px; } .advanced-settings-content.visible { display: block; } .advanced-setting-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 12px; } .advanced-setting-row:first-child { margin-top: 10px; } .quality-row { flex-wrap: wrap; } .quality-row .setting-title { flex: 1; min-width: 100px; } .setting-label { flex-shrink: 0; } .setting-controls { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; } .setting-row-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } .setting-title { font-size: 0.85em; color: var(--title-color); font-weight: 500; } .setting-value { font-size: 0.8em; color: var(--accent-color); font-weight: 600; } /* Quality Slider */ .quality-slider-container { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } .quality-slider { flex: 1; height: 6px; border-radius: 3px; background: var(--border-color); -webkit-appearance: none; appearance: none; cursor: pointer; } .quality-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--accent-color); cursor: pointer; } .quality-slider::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%; background: var(--accent-color); border: none; cursor: pointer; } .quality-input-wrapper { display: flex; align-items: center; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 4px; padding: 4px 8px; } .quality-input { width: 40px; border: none; background: transparent; text-align: center; font-size: 0.85em; color: var(--title-color); font-weight: 600; } .quality-input:focus { outline: none; } .quality-unit { font-size: 0.75em; color: var(--label-color); } .quality-slider-container-inline { display: flex; align-items: center; gap: 6px; flex: 1; max-width: 180px; min-width: 0; } .quality-slider-container-inline .quality-slider { flex: 1; min-width: 50px; } .quality-slider-container-inline .quality-input-wrapper { padding: 2px 4px; flex-shrink: 0; } .quality-slider-container-inline .quality-input { width: 32px; font-size: 0.8em; } /* Quality Presets - Compact Single Row */ .quality-presets { display: flex; gap: 0; background: var(--border-color); border-radius: 6px; padding: 3px; width: fit-content; } .quality-preset { padding: 6px 14px; background: transparent; border: none; border-radius: 4px; font-size: 0.75em; font-weight: 500; color: var(--label-color); text-align: center; cursor: pointer; transition: all 0.15s; white-space: nowrap; } .quality-preset:hover { background: var(--accent-color-10); color: var(--accent-color); } .quality-preset.active { background: var(--accent-color); color: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2); } /* Toggle Switch */ .setting-toggle { display: flex; align-items: center; justify-content: space-between; } .toggle-switch { position: relative; width: 44px; height: 24px; flex-shrink: 0; } .toggle-switch input { opacity: 0; width: 0; height: 0; } .toggle-slider { position: absolute; cursor: pointer; inset: 0; background: var(--border-color); border-radius: 24px; transition: 0.2s; } .toggle-slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background: white; border-radius: 50%; transition: 0.2s; } .toggle-switch input:checked + .toggle-slider { background: var(--accent-color); } .toggle-switch input:checked + .toggle-slider:before { transform: translateX(20px); } .setting-desc { font-size: 0.75em; color: var(--label-color); margin-top: 4px; line-height: 1.3; } /* Rotation Buttons */ .rotation-buttons { display: flex; gap: 0; background: var(--border-color); border-radius: 6px; padding: 3px; } .rotation-btn { display: flex; align-items: center; gap: 4px; padding: 6px 14px; background: transparent; border: none; border-radius: 4px; font-size: 0.75em; font-weight: 500; color: var(--label-color); cursor: pointer; transition: all 0.15s; white-space: nowrap; } .rotation-btn:hover { background: var(--accent-color-10); color: var(--accent-color); } .rotation-btn.active { background: var(--accent-color); color: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2); } .rotation-btn .rot-icon { font-size: 1.1em; } /* Overlap Selector */ .overlap-selector { display: flex; gap: 0; background: var(--border-color); border-radius: 6px; padding: 3px; } .overlap-btn { padding: 6px 14px; background: transparent; border: none; border-radius: 4px; font-size: 0.75em; font-weight: 500; color: var(--label-color); cursor: pointer; transition: all 0.15s; white-space: nowrap; } .overlap-btn:hover { background: var(--accent-color-10); color: var(--accent-color); } .overlap-btn.active { background: var(--accent-color); color: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2); } /* Image Picker Grid Styles */ .image-picker-section { margin-top: 15px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; max-height: 350px; overflow-y: auto; } .image-picker-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } .image-picker-title { font-size: 0.85em; font-weight: 600; color: var(--title-color); } .image-picker-count { font-size: 0.75em; color: var(--label-color); } .image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; } .image-item { position: relative; border: 2px solid var(--border-color); border-radius: 6px; overflow: hidden; cursor: pointer; transition: all 0.2s; background: var(--card-bg); } .image-item:hover { border-color: var(--accent-color); transform: scale(1.02); } .image-item.state-0 { border-color: var(--border-color); } .image-item.state-1 { border-color: #3498db; background: rgba(52, 152, 219, 0.1); } .image-item.state-2 { border-color: #e74c3c; background: rgba(231, 76, 60, 0.1); } .image-item.state-3 { border-color: #9b59b6; background: rgba(155, 89, 182, 0.1); } .image-item.crop-preview { border-color: #16a085; background: rgba(22, 160, 133, 0.1); } .image-item.cover-locked { border-color: #e67e22; background: rgba(230, 126, 34, 0.1); cursor: default; opacity: 0.85; } .image-item.cover-locked:hover { transform: none; border-color: #e67e22; } .image-item.cover-locked .image-state-badge { background: #e67e22; } .image-item.separator-locked { border-color: #1abc9c; background: rgba(26, 188, 156, 0.1); cursor: default; opacity: 0.85; } .image-item.separator-locked:hover { transform: none; border-color: #1abc9c; } .image-item.separator-locked .image-state-badge { background: #1abc9c; } .image-item img { width: 100%; height: 80px; object-fit: contain; display: block; background: #fff; } .image-state-badge { position: absolute; top: 2px; right: 2px; padding: 2px 6px; border-radius: 4px; font-size: 0.65em; font-weight: 600; color: white; } .state-0 .image-state-badge { background: var(--label-color); } .state-1 .image-state-badge { background: #3498db; } .state-2 .image-state-badge { background: #e74c3c; } .state-3 .image-state-badge { background: #9b59b6; } .crop-preview .image-state-badge { background: #16a085; } /* Preview Overlay (shown on hover) */ .image-preview-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 20px; /* Above the filename */ pointer-events: none; opacity: 0; transition: opacity 0.15s; z-index: 5; display: flex; align-items: center; justify-content: center; } .image-item:hover .image-preview-overlay { opacity: 1; } /* Split lines - thick colored stripes */ .split-lines { position: absolute; top: 0; left: 0; right: 0; bottom: 0; } .split-line { position: absolute; top: 0; bottom: 0; width: 4px; transform: translateX(-50%); border-radius: 2px; } .split-line.split-h { background: linear-gradient(180deg, #3498db 0%, #2980b9 100%); box-shadow: 0 0 6px rgba(0, 0, 0, 0.5), 0 0 2px rgba(52, 152, 219, 0.8); } .split-line.split-v { background: linear-gradient(180deg, #e74c3c 0%, #c0392b 100%); box-shadow: 0 0 6px rgba(0, 0, 0, 0.5), 0 0 2px rgba(231, 76, 60, 0.8); } /* Rotate image on hover for rotation states */ .image-item.rotate-cw:hover img { transform: rotate(90deg) scale(0.75); transition: transform 0.2s ease; } .image-item.rotate-ccw:hover img { transform: rotate(-90deg) scale(0.75); transition: transform 0.2s ease; } .image-item img { transition: transform 0.2s ease; } .image-name { font-size: 0.65em; color: var(--label-color); padding: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; } .state-legend { display: flex; gap: 8px; font-size: 0.7em; margin: 10px 0; flex-wrap: wrap; padding: 8px 12px; background: rgba(0,0,0,0.02); border-radius: 6px; align-items: center; } .legend-item { display: flex; align-items: center; gap: 5px; white-space: nowrap; } .legend-item.locked { opacity: 0.7; } .legend-btn { display: flex; align-items: center; gap: 5px; white-space: nowrap; padding: 4px 10px; border: 1px solid var(--btn-color); border-radius: 4px; background: transparent; color: var(--text-color); cursor: pointer; transition: all 0.15s; font-size: inherit; font-family: inherit; } .legend-btn:hover { background: var(--btn-color); color: white; } .legend-btn .legend-color { background: var(--btn-color); } .legend-color { width: 10px; height: 10px; border-radius: 3px; } .file-info { color: var(--label-color); font-size: 0.85em; margin: 8px 0; } .no-files { text-align: center; color: #95a5a6; padding: 40px; font-style: italic; } .message { padding: 15px; border-radius: 4px; margin: 15px 0; } .message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } .contents-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .summary-inline { color: var(--label-color); font-size: 0.9em; } #progress-container { display: none; margin-top: 10px; } #progress-bar { width: 100%; height: 20px; background-color: #e0e0e0; border-radius: 10px; overflow: hidden; } #progress-fill { height: 100%; background-color: #27ae60; width: 0%; transition: width 0.3s; } #progress-fill.no-transition { transition: none !important; } #progress-text { text-align: center; margin-top: 5px; font-size: 0.9em; color: var(--label-color); } .folder-form { margin-top: 10px; } .folder-input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 1em; margin-bottom: 10px; box-sizing: border-box; } .folder-btn { background-color: #f39c12; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 1em; width: 100%; } .folder-btn:hover { background-color: #d68910; } .folder-btn:disabled { background-color: #bdc3c7; cursor: not-allowed; } /* Action button styles */ .delete-btn, .rename-btn, .move-btn { background: none; border: none; cursor: pointer; font-size: 1.1em; padding: 4px 8px; border-radius: 4px; transition: all 0.15s; } .delete-btn { color: #95a5a6; } .delete-btn:hover { background-color: #fee; color: #e74c3c; } .rename-btn { color: #2980b9; } .rename-btn:hover { background-color: #e8f4fd; } .move-btn { color: #16a085; } .move-btn:hover { background-color: #e6f7f4; } .actions-col { width: 140px; text-align: center; } .action-icon-group { display: inline-flex; align-items: center; justify-content: center; gap: 6px; } /* Failed uploads banner */ .failed-uploads-banner { background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin-bottom: 15px; display: none; } .failed-uploads-banner.show { display: block; } .failed-uploads-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } .failed-uploads-title { font-weight: 600; color: #856404; margin: 0; } .dismiss-btn { background: none; border: none; font-size: 1.2em; cursor: pointer; color: #856404; padding: 0; line-height: 1; } .dismiss-btn:hover { color: #533f03; } .failed-file-item { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #ffe69c; } .failed-file-item:last-child { border-bottom: none; } .failed-file-info { flex: 1; } .failed-file-name { font-weight: 500; color: #856404; } .failed-file-error { font-size: 0.85em; color: #856404; opacity: 0.8; } .retry-btn { background-color: #ffc107; color: #533f03; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 0.9em; font-weight: 600; } .retry-btn:hover { background-color: #e0a800; } .retry-all-btn { background-color: #ffc107; color: #533f03; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 0.95em; font-weight: 600; margin-top: 10px; } .retry-all-btn:hover { background-color: #e0a800; } /* Delete modal */ .delete-warning { color: #e74c3c; font-weight: 600; margin: 10px 0; } .delete-item-name { font-weight: 600; color: var(--title-color); word-break: break-all; } .delete-btn-confirm { background-color: #e74c3c; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 1em; width: 100%; } .delete-btn-confirm:hover { background-color: #c0392b; } .delete-btn-cancel { background-color: #95a5a6; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 1em; width: 100%; margin-top: 10px; } .delete-btn-cancel:hover { background-color: var(--label-color); } .rename-btn-confirm { background-color: #3498db; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 1em; width: 100%; } .rename-btn-confirm:hover { background-color: #2e86c1; } .move-btn-confirm { background-color: #16a085; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 1em; width: 100%; } .move-btn-confirm:hover { background-color: #138d75; } .loader-container { display: flex; justify-content: center; align-items: center; margin: 20px 0; } .loader { width: 48px; height: 48px; border: 5px solid #AAA; border-bottom-color: transparent; border-radius: 50%; display: inline-block; box-sizing: border-box; animation: rotation 1s linear infinite; } @keyframes rotation { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } /* Mobile responsive styles */ @media (max-width: 600px) { body { padding: 10px; font-size: 14px; } .card { line-break: anywhere; padding: 12px; margin: 10px 0; } .page-header { gap: 10px; margin-bottom: 12px; padding-bottom: 10px; } .page-header-left { gap: 8px; } h1 { font-size: 1.3em; } .breadcrumb-inline { font-size: 0.95em; } .nav-links a { padding: 8px 12px; margin-right: 6px; font-size: 0.9em; } .action-buttons { gap: 6px; } .action-btn { padding: 8px 10px; font-size: 0.85em; } .file-table th, .file-table td { padding: 8px 6px; font-size: 0.9em; } .file-table th { font-size: 0.85em; } .file-icon { margin-right: 4px; } .epub-badge, .folder-badge { padding: 2px 5px; font-size: 0.65em; } .contents-header { margin-bottom: 8px; flex-wrap: wrap; gap: 4px; } .summary-inline { font-size: 0.8em; } .modal { padding: 15px; } .modal h3 { font-size: 1.1em; } .actions-col { width: 120px; } .delete-btn, .rename-btn, .move-btn { font-size: 1em; padding: 2px 4px; } .action-icon-group { gap: 4px; } .no-files { padding: 20px; font-size: 0.9em; } .handedness-btn { padding: 6px 10px; } .handedness-buttons { width: 100%; justify-content: center; } .quality-slider-container-inline { max-width: 215px; gap: 4px; } .quality-slider-container-inline .quality-slider { min-width: 40px; } .quality-slider-container-inline .quality-input { width: 28px; } } /* Enhanced Logging System Styles */ .log-section { margin-top: 15px; display: none; } .log-section.visible { display: block; } .log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } .log-title { font-weight: 600; color: var(--title-color); font-size: 0.95em; } .log-container { background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 6px; padding: 12px; max-height: 300px; overflow-y: auto; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace; font-size: 0.8em; } .log-entry { color: var(--font-color); display: flex; gap: 8px; align-items: flex-start; margin-bottom: 4px; line-height: 1.4; } .log-entry.success { color: var(--accent-color); } .log-entry.error { color: #e74c3c; } .log-entry.warning { color: #f39c12; } .log-entry.info { color: #3498db; } .log-timestamp { color: var(--label-color); font-size: 0.75em; min-width: 52px; opacity: 0.7; } .log-tag { font-size: 0.7em; font-weight: 600; padding: 2px 6px; border-radius: 3px; text-transform: uppercase; min-width: 56px; text-align: center; } .log-tag.convert { background: rgba(110, 154, 130, 0.15); color: var(--accent-color); } .log-tag.split { background: rgba(243, 156, 18, 0.15); color: #f39c12; } .log-tag.fix { background: rgba(39, 174, 96, 0.15); color: #27ae60; } .log-tag.skip { background: rgba(127, 140, 141, 0.15); color: #7f8c8d; } .log-tag.info { background: rgba(52, 152, 219, 0.15); color: #3498db; } .log-tag.error { background: rgba(231, 76, 60, 0.15); color: #e74c3c; } .log-tag.done { background: rgba(39, 174, 96, 0.15); color: #27ae60; } .log-message { flex: 1; word-break: break-word; } .log-detail { color: var(--label-color); font-size: 0.9em; } .log-summary { background: rgba(110, 154, 130, 0.08); border: 1px solid var(--border-color); border-radius: 6px; margin-top: 12px; overflow: hidden; } .log-summary-title { background: rgba(110, 154, 130, 0.15); padding: 8px 12px; font-weight: 600; font-size: 0.85em; border-bottom: 1px solid var(--border-color); color: var(--accent-color); } .log-summary-table { width: 100%; font-size: 0.8em; } .log-summary-table td { padding: 6px 12px; border-bottom: 1px solid var(--border-color); } .log-summary-table tr:last-child td { border-bottom: none; } .log-summary-table td:first-child { color: var(--label-color); width: 45%; } .log-summary-table td:last-child { color: var(--font-color); font-weight: 500; text-align: right; } .log-summary-table .highlight { color: var(--accent-color); } .log-summary-table .saved { color: #27ae60; } .log-summary-table .increased { color: #f39c12; } </style></head><body><h1>📚 CrossPoint Reader</h1>
<div class="nav-links"> <a href="/">Home</a> <a href="/files" class="active">File Manager</a> <a href="/settings">Settings</a> <a href="/fonts">Fonts</a></div>
<div class="page-header"> <div class="page-header-left"> <h2>📁 File Manager</h2> </div>
<div class="action-buttons"> <button class="action-btn upload-action-btn" onclick="openUploadModal()">📤 Upload</button> <button class="action-btn folder-action-btn" onclick="openFolderModal()">📁 New Folder</button> <button class="action-btn delete-action-btn" onclick="openDeleteSelectedModal()">🗑️ Delete Selected</button> </div></div>
<!-- Failed Uploads Banner --><div class="failed-uploads-banner" id="failedUploadsBanner"> <div class="failed-uploads-header"> <h3 class="failed-uploads-title">⚠️ Some files failed to upload</h3> <button class="dismiss-btn" onclick="dismissFailedUploads()" title="Dismiss">×</button> </div> <div id="failedFilesList"></div> <button class="retry-all-btn" onclick="retryAllFailedUploads()">Retry All Failed Uploads</button></div>
<div class="card"> <div class="contents-header"> <div class="breadcrumb-inline" id="directory-breadcrumbs"></div> <span class="summary-inline" id="folder-summary"></span> </div>
<div id="file-table"> <div class="loader-container"> <span class="loader"></span> </div> </div></div>
<div class="card"> <p style="text-align: center; color: #95a5a6; margin: 0;"> CrossPoint E-Reader • Open Source </p></div>
<!-- Upload Modal --><div class="modal-overlay" id="uploadModal"> <div class="modal"> <button class="modal-close" id="uploadModalClose" onclick="closeUploadModal()">×</button> <h3>📤 Upload file</h3> <div class="upload-form"> <p class="file-info">Select a file to upload to <strong id="uploadPathDisplay"></strong></p> <div class="drop-zone" id="dropZone"> <input type="file" id="fileInput" onchange="validateFile()" multiple> <p class="drop-zone-hint">⬇ Drop files here — or click to browse</p> </div> <div class="picker-columns" id="pickerColumns"> <div class="convert-options" id="convertOptions" style="display:none;"> <div class="convert-row"> <label class="convert-checkbox"> <input type="checkbox" id="convertBeforeUpload" onchange="toggleConvertOptions()"> <span>Optimize EPUB</span> </label> <span class="convert-separator">—</span> <span class="advanced-options-toggle" id="advancedOptionsToggle" onclick="toggleAdvancedOptions()"> <span class="advanced-options-arrow" id="advancedOptionsArrow">▶</span> <span class="advanced-options-text">Advanced Mode</span> </span> </div>
<div id="convertInfo" style="display:none; margin-bottom: 10px; font-size: 0.9em;"> </div>
<div class="advanced-setting-row"> <div class="setting-label"> <div class="setting-title">Rename from Book Metadata</div> <div class="setting-desc">Use Title - Author.epub when available</div> </div> <div class="setting-controls"> <label class="toggle-switch"> <input type="checkbox" id="renameFromMetadataToggle" onchange="updateUploadSettingsPersistence()"> <span class="toggle-slider"></span> </label> </div> </div>
<div class="convert-settings" id="convertSettings" style="display:block;"> <span>⚫ True-Grayscale</span> <span id="convertSizeSummary">📏 Max 480×800px</span> <span>📦 85% JPEG</span> <span>🔧 Fix SVG</span> </div> <!-- Advanced Settings Content --> <div class="advanced-settings-content" id="advancedSettingsContent"> <!-- JPEG Quality --> <div class="advanced-setting-row quality-row"> <div class="setting-title">📦 JPEG Quality</div> <div class="quality-presets"> <button class="quality-preset" data-value="30" onclick="setQualityPreset(30)" title="Minimum quality">30%</button> <button class="quality-preset" data-value="45" onclick="setQualityPreset(45)" title="Low quality">45%</button> <button class="quality-preset" data-value="60" onclick="setQualityPreset(60)" title="Medium-low quality">60%</button> <button class="quality-preset" data-value="75" onclick="setQualityPreset(75)" title="Medium quality">75%</button> <button class="quality-preset active" data-value="85" onclick="setQualityPreset(85)" title="High quality">85%</button> <button class="quality-preset" data-value="95" onclick="setQualityPreset(95)" title="Maximum quality">95%</button> </div> <!-- Hidden inputs to maintain compatibility --> <input type="hidden" id="qualitySlider" value="85"> <input type="hidden" id="qualityInput" value="85"> </div> <!-- Grayscale Toggle (commented for e-ink optimization, kept for compatibility with other devices/web apps) <div class="advanced-setting-row" style="display:none;"> <div class="setting-toggle"> <div> <div class="setting-title">⚫ True-Grayscale</div> <div class="setting-desc">BT.601 luminance formula (recommended for e-ink)</div> </div> <label class="toggle-switch"> <input type="checkbox" id="grayscaleToggle" checked onchange="updateQualitySettings()"> <span class="toggle-slider"></span> </label> </div> </div> --> <!-- Auto-crop Margins --> <div class="advanced-setting-row"> <div class="setting-label"> <div class="setting-title">✂ Auto-crop Margins</div> <div class="setting-desc">Trim uniform borders and scale image content to fit</div> </div> <div class="setting-controls"> <label class="toggle-switch"> <input type="checkbox" id="autoCropToggle" onchange="updateQualitySettings()"> <span class="toggle-slider"></span> </label> </div> </div> <!-- Target Device --> <div class="advanced-setting-row" id="deviceSettingRow"> <div class="setting-title">📱 Target Device</div> <div class="setting-controls"> <div class="rotation-buttons"> <button class="rotation-btn device-btn active" data-value="auto" onclick="setDeviceTarget('auto')"> <span id="deviceAutoLabel">Auto</span> </button> <button class="rotation-btn device-btn" data-value="X4" onclick="setDeviceTarget('X4')">X4</button> <button class="rotation-btn device-btn" data-value="X3" onclick="setDeviceTarget('X3')">X3</button> </div> </div> </div> <!-- Rotation Direction --> <div class="advanced-setting-row" id="rotationSettingRow"> <div class="setting-title">↻ Rotation Direction</div> <div class="setting-controls"> <div class="rotation-buttons"> <button class="rotation-btn" id="rotationCCW" onclick="setHandedness('left')"> <span class="rot-icon">↺</span> CCW </button> <button class="rotation-btn active" id="rotationCW" onclick="setHandedness('right')"> <span class="rot-icon">↻</span> CW </button> </div> </div> </div> <!-- Overlap Settings --> <div class="advanced-setting-row" id="overlapSettingRow"> <div class="setting-title">📐 Min Overlap</div> <div class="setting-controls"> <div class="overlap-selector"> <button class="overlap-btn active" data-value="5" onclick="setOverlap(5)">5%</button> <button class="overlap-btn" data-value="10" onclick="setOverlap(10)">10%</button> <button class="overlap-btn" data-value="15" onclick="setOverlap(15)">15%</button> </div> </div> </div> <!-- Conversion Log Export --> <div class="advanced-setting-row"> <div class="setting-label"> <div class="setting-title">📥 Auto-download Log</div> <div class="setting-desc">Export detailed log with statistics</div> </div> <div class="setting-controls"> <label class="toggle-switch"> <input type="checkbox" id="export-log-checkbox" onchange="updateUploadSettingsPersistence()"> <span class="toggle-slider"></span> </label> </div> </div> <div class="advanced-setting-row"> <div class="setting-label"> <div class="setting-title">Remember Settings</div> <div class="setting-desc">Store these upload options in this browser</div> </div> <div class="setting-controls"> <label class="toggle-switch"> <input type="checkbox" id="rememberUploadSettings" onchange="updateUploadSettingsPersistence()"> <span class="toggle-slider"></span> </label> </div> </div> </div><!-- advanced-settings-content --> </div><!-- end convert-options --> <!-- Image Picker Section - in picker-columns for two-column layout --> <div class="image-picker-section" id="imagePickerSection" style="display:none;"> <div class="image-picker-header"> <span class="image-picker-title">🖼️ Image Processing Options</span> <span class="image-picker-count" id="imagePickerCount">0 images</span> </div> <div class="state-legend"> <div class="legend-item locked"> <span>🔒</span> <span>Cover</span> </div> <div class="legend-item locked"> <span style="color:#1abc9c">✦</span> <span>Separator</span> </div> <button class="legend-btn" style="--btn-color:var(--label-color)" onclick="applyStateToAll(0)"> <div class="legend-color"></div> <span>Normal</span> </button> <button class="legend-btn" style="--btn-color:#3498db" onclick="applyStateToAll(1)"> <div class="legend-color"></div> <span>H-Split</span> </button> <button class="legend-btn" style="--btn-color:#e74c3c" onclick="applyStateToAll(2)"> <div class="legend-color"></div> <span>V-Split</span> </button> <button class="legend-btn" style="--btn-color:#9b59b6" onclick="applyStateToAll(3)"> <div class="legend-color"></div> <span>Rotate</span> </button> </div> <div class="image-grid" id="imageGrid"></div> </div> </div><!-- end picker-columns --> <!-- Sync compatibility warning - outside picker-columns --> <div class="convert-warning" id="convertWarning" style="display:none;" role="alert" aria-live="polite"> ⚠️ Converting will modify files and can break hash‑based sync. ⚠️<br> Please back up or disable sync before proceeding. </div> <!-- Optimize & Upload button - shown when picker is visible, replaces Upload --> <button id="startConversionBtn" class="upload-btn optimize" style="display:none;" onclick="startConversionWithImageStates()">Optimize & Upload</button> <button id="uploadBtn" class="upload-btn" onclick="uploadFile()" disabled>Upload</button> <button class="delete-btn-cancel" onclick="handleCancelUploadModal()">Cancel</button> <div id="progress-container"> <div id="progress-bar"><div id="progress-fill"></div></div> <div id="progress-text"></div> </div> <div class="log-section" id="log-section"> <div class="log-header"> <div class="log-title">📋 Conversion Log</div> </div> <div class="log-container" id="log-container"></div> </div> </div> </div></div>
<!-- New Folder Modal --><div class="modal-overlay" id="folderModal"> <div class="modal"> <button class="modal-close" onclick="closeFolderModal()">×</button> <h3>📁 New Folder</h3> <div class="folder-form"> <p class="file-info">Create a new folder in <strong id="folderPathDisplay"></strong></p> <input type="text" id="folderName" class="folder-input" placeholder="Folder name..." oninput="document.getElementById('createFolderBtn').disabled = !this.value.trim();"> <button class="folder-btn" id="createFolderBtn" onclick="createFolder()" disabled>Create Folder</button> <button class="delete-btn-cancel" onclick="closeFolderModal()">Cancel</button> </div> </div></div>
<!-- Delete Confirmation Modal --><div class="modal-overlay" id="deleteModal"> <div class="modal"> <button class="modal-close" onclick="closeDeleteModal()">×</button> <h3>🗑️ Delete Item(s)</h3> <div class="folder-form"> <p class="delete-warning">⚠️ This action cannot be undone!</p> <p class="file-info">Are you sure you want to delete the following item(s)?</p> <div id="deleteItemList" style="max-height:240px; overflow:auto; margin-bottom:10px;"></div> <button class="delete-btn-confirm" onclick="confirmDelete()">Delete</button> <button class="delete-btn-cancel" onclick="closeDeleteModal()">Cancel</button> </div> </div></div>
<!-- Rename Modal --><div class="modal-overlay" id="renameModal"> <div class="modal"> <button class="modal-close" onclick="closeRenameModal()">×</button> <h3>✏️ Rename File</h3> <div class="folder-form"> <p class="file-info">Renaming <strong id="renameItemName"></strong></p> <input type="text" id="renameNewName" class="folder-input" placeholder="New file name..."> <input type="hidden" id="renameItemPath"> <button class="rename-btn-confirm" onclick="confirmRename()">Rename</button> <button class="delete-btn-cancel" onclick="closeRenameModal()">Cancel</button> </div> </div></div>
<!-- Move Modal --><div class="modal-overlay" id="moveModal"> <div class="modal"> <button class="modal-close" onclick="closeMoveModal()">×</button> <h3>📂 Move File</h3> <div class="folder-form"> <p class="file-info">Moving <strong id="moveItemName"></strong></p> <input type="text" id="moveDestPath" class="folder-input" list="moveFolderOptions" placeholder="/Destination/Folder"> <datalist id="moveFolderOptions"></datalist> <input type="hidden" id="moveItemPath"> <button class="move-btn-confirm" onclick="confirmMove()">Move</button> <button class="delete-btn-cancel" onclick="closeMoveModal()">Cancel</button> </div> </div></div>
<!-- Image Preview Modal --><div class="modal-overlay" id="imagePreviewModal"> <div class="modal image-preview-mode"> <button class="modal-close" onclick="closeImagePreview()">×</button> <h3>🖼️ Preview</h3> <div class="folder-form"> <p class="file-info"><strong id="imagePreviewName"></strong></p> <div class="image-preview-stage"> <img id="imagePreviewImg" alt=""> </div> <a id="imagePreviewDownload" class="move-btn-confirm" rel="noopener noreferrer" target="_blank" href="#">Download</a> </div> </div></div>
<script> // get current path from query parameter const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
if (currentPath !== '/') { const leaf = currentPath.split('/').filter(Boolean).pop(); if (leaf) document.title = leaf + ' - Files - CrossPoint Reader'; }
// Network status monitoring let isNetworkOnline = navigator.onLine;
// Add network status listeners window.addEventListener('online', () => { console.log('[Network] Online event fired'); isNetworkOnline = true; showNotification('Network connection restored', 'success'); });
window.addEventListener('offline', () => { console.log('[Network] Offline event fired'); isNetworkOnline = false; showNotification('Network connection lost', 'warning'); });
// Initialize network status console.log('[Network] Initial status:', isNetworkOnline ? 'online' : 'offline');
function escapeHtml(unsafe) { return unsafe .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); }
function showNotification(message, type = 'info') { // Create notification element if it doesn't exist let notification = document.getElementById('notification'); if (!notification) { notification = document.createElement('div'); notification.id = 'notification'; notification.style.cssText = ` position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 4px; color: white; font-weight: 500; z-index: 10000; max-width: 300px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); transition: all 0.3s ease; `; document.body.appendChild(notification); }
// Set styles based on type const styles = { 'success': 'background-color: #27ae60;', 'error': 'background-color: #e74c3c;', 'warning': 'background-color: #f39c12;', 'info': 'background-color: #3498db;' }; notification.style.cssText += styles[type] || styles['info']; notification.textContent = message;
// Show notification notification.style.opacity = '1'; notification.style.transform = 'translateX(0)';
// Auto-hide after 5 seconds setTimeout(() => { notification.style.opacity = '0'; notification.style.transform = 'translateX(100%)'; setTimeout(() => { if (notification.parentNode) { notification.parentNode.removeChild(notification); } }, 300); }, 5000); }
function formatFileSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)).toLocaleString() + ' ' + sizes[i]; }
async function hydrate() { // Fetch CrossPoint version fetchVersion();
// Close modals when clicking overlay - call proper cleanup functions document.querySelectorAll('.modal-overlay').forEach(function(overlay) { overlay.addEventListener('click', function(e) { if (e.target === overlay) { // Call the appropriate close function for each modal to ensure cleanup if (overlay.id === 'uploadModal') return closeUploadModal(); if (overlay.id === 'folderModal') return closeFolderModal(); if (overlay.id === 'deleteModal') return closeDeleteModal(); if (overlay.id === 'renameModal') return closeRenameModal(); if (overlay.id === 'moveModal') return closeMoveModal(); if (overlay.id === 'imagePreviewModal') return closeImagePreview(); overlay.classList.remove('open'); } }); });
const breadcrumbs = document.getElementById('directory-breadcrumbs'); const fileTable = document.getElementById('file-table');
const segments = currentPath.split('/').filter(Boolean); breadcrumbs.replaceChildren();
const appendSep = function() { const sep = document.createElement('span'); sep.className = 'sep'; sep.textContent = '›'; breadcrumbs.appendChild(sep); };
const appendLink = function(label, href) { const link = document.createElement('a'); link.href = href; link.textContent = label; breadcrumbs.appendChild(link); };
const appendCurrent = function(label) { const current = document.createElement('span'); current.className = 'current'; current.textContent = label; breadcrumbs.appendChild(current); };
if (segments.length === 0) { appendCurrent('🏠 Home'); } else { appendLink('🏠 Home', '/files'); segments.forEach(function(segment, index) { appendSep(); if (index === segments.length - 1) { appendCurrent(segment); } else { const path = '/' + segments.slice(0, index + 1).join('/'); appendLink(segment, '/files?path=' + encodeURIComponent(path)); } }); }
let files = []; try { const response = await fetch('/api/files?path=' + encodeURIComponent(currentPath) + '&_=' + Date.now()); if (!response.ok) { throw new Error('Failed to load files: ' + response.status + ' ' + response.statusText); } files = await response.json(); } catch (e) { console.error(e); fileTable.innerHTML = '<div class="no-files">An error occurred while loading the files</div>'; return; }
let folderCount = 0; let totalSize = 0; files.forEach(file => { if (file.isDirectory) folderCount++; totalSize += file.size; });
const fileCount = files.length - folderCount; const folderLabel = folderCount === 1 ? 'folder' : 'folders'; const fileLabel = fileCount === 1 ? 'file' : 'files'; document.getElementById('folder-summary').innerHTML = `${folderCount} ${folderLabel}, ${fileCount} ${fileLabel}, ${formatFileSize(totalSize)}`;
if (files.length === 0) { fileTable.innerHTML = '<div class="no-files">This folder is empty</div>'; } else { let fileTableContent = '<table class="file-table">';
// Add select-all checkbox column fileTableContent += '<tr><th style="width:40px"><input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll(this)"></th><th>Name</th><th>Type</th><th>Size</th><th class="actions-col">Actions</th></tr>';
const sortedFiles = files.sort((a, b) => { // Directories first, then epub files, then other files, alphabetically within each group if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; if (a.isEpub && !b.isEpub) return -1; if (!a.isEpub && b.isEpub) return 1; return a.name.localeCompare(b.name); });
sortedFiles.forEach(file => { if (file.isDirectory) { let folderPath = currentPath; if (!folderPath.endsWith("/")) folderPath += "/"; folderPath += file.name;
// Checkbox cell + folder row fileTableContent += `<tr class="folder-row">`; fileTableContent += `<td><input type="checkbox" class="select-item" data-path="${encodeURIComponent(folderPath)}" data-name="${escapeHtml(file.name)}" data-type="folder"></td>`; fileTableContent += `<td><span class="file-icon">📁</span><a href="/files?path=${encodeURIComponent(folderPath)}" class="folder-link">${escapeHtml(file.name)}</a></td>`; fileTableContent += '<td><span class="folder-badge">FOLDER</span></td>'; fileTableContent += '<td>-</td>'; fileTableContent += `<td class="actions-col"><div class="action-icon-group"><button class="delete-btn" data-action="delete" data-name="${escapeHtml(file.name)}" data-path="${escapeHtml(folderPath)}" data-folder="true" title="Delete folder">🗑️</button></div></td>`; fileTableContent += '</tr>'; } else { let filePath = currentPath; if (!filePath.endsWith("/")) filePath += "/"; filePath += file.name;
// Checkbox cell + file row fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}">`; fileTableContent += `<td><input type="checkbox" class="select-item" data-path="${encodeURIComponent(filePath)}" data-name="${escapeHtml(file.name)}" data-type="file"></td>`; const fileIsImage = isImageFile(file.name); fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : (fileIsImage ? '🖼️' : '📄')}</span>`; if (fileIsImage) { fileTableContent += `<a href="${downloadUrl(filePath)}" class="file-link image-preview-link">${escapeHtml(file.name)}</a>`; } else { fileTableContent += `<a rel="noopener noreferrer" target="_blank" href="${downloadUrl(filePath)}" class="file-link">${escapeHtml(file.name)}</a>`; } fileTableContent += '</td>'; fileTableContent += file.isEpub ? '<td><span class="epub-badge">EPUB</span></td>' : `<td>${escapeHtml(file.name.split('.').pop().toUpperCase())}</td>`; fileTableContent += `<td>${formatFileSize(file.size)}</td>`; fileTableContent += `<td class="actions-col"><div class="action-icon-group">`; fileTableContent += `<button class="move-btn" data-action="move" data-name="${escapeHtml(file.name)}" data-path="${escapeHtml(filePath)}" title="Move file">📂</button>`; fileTableContent += `<button class="rename-btn" data-action="rename" data-name="${escapeHtml(file.name)}" data-path="${escapeHtml(filePath)}" title="Rename file">✏️</button>`; fileTableContent += `<button class="delete-btn" data-action="delete" data-name="${escapeHtml(file.name)}" data-path="${escapeHtml(filePath)}" data-folder="false" title="Delete file">🗑️</button>`; fileTableContent += `</div></td>`; fileTableContent += '</tr>'; } });
fileTableContent += '</table>'; fileTable.innerHTML = fileTableContent; } }
// Image preview function isImageFile(name) { return /\.(png|jpe?g|bmp|gif|webp)$/i.test(name); }
function downloadUrl(filePath) { return `/download?path=${encodeURIComponent(filePath)}`; }
function openImagePreview(url, name) { const img = document.getElementById('imagePreviewImg'); document.getElementById('imagePreviewName').textContent = name; img.src = url; img.alt = name; document.getElementById('imagePreviewDownload').href = url; document.getElementById('imagePreviewModal').classList.add('open'); }
function closeImagePreview() { document.getElementById('imagePreviewModal').classList.remove('open'); // Clear src to free the decoded image and avoid showing the stale one on reopen. document.getElementById('imagePreviewImg').src = ''; }
// Modal functions function openUploadModal() { imageStates = {}; restoreUploadSettingsFromStorage();
// Hide convert options when opening modal (no files selected initially) const convertOptions = document.getElementById('convertOptions'); if (convertOptions) { convertOptions.style.display = 'none'; }
// Hide log section from previous session const logSection = document.getElementById('log-section'); if (logSection) logSection.classList.remove('visible'); const logContainer = document.getElementById('log-container'); if (logContainer) logContainer.innerHTML = '';
document.getElementById('uploadPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath; document.getElementById('uploadModal').classList.add('open'); }
function handleCancelUploadModal() { if (!isUploadInProgress) { // No process running: close the modal normally closeUploadModal(); return; } // Process running: stop it, keep modal open for retry operationCancelled = true; if (currentUploadWs) { currentUploadWs.close(); currentUploadWs = null; } if (currentUploadXhr) { currentUploadXhr.abort(); currentUploadXhr = null; } // isUploadInProgress and UI are restored by restoreAfterCancel() from the async handlers }
function restoreAfterCancel() { operationCancelled = false; isUploadInProgress = false; document.getElementById('uploadModalClose').classList.remove('disabled'); document.getElementById('fileInput').disabled = false; const progressFill = document.getElementById('progress-fill'); const progressText = document.getElementById('progress-text'); progressFill.style.width = '0%'; progressFill.style.backgroundColor = '#e74c3c'; progressText.style.color = '#e74c3c'; progressText.textContent = 'Upload cancelled by User!'; // Re-enable the action button (uploadBtn is always visible at this point; // its text is already "Upload" or "Optimize & Upload" depending on checkbox state) document.getElementById('uploadBtn').disabled = false; }
function closeUploadModal() { // Prevent closing during upload/conversion if (isUploadInProgress) { return; } document.getElementById('uploadModal').classList.remove('open'); const fileInput = document.getElementById('fileInput'); fileInput.value = ''; fileInput.classList.remove('has-files'); fileInput.disabled = false; const uploadBtn = document.getElementById('uploadBtn'); uploadBtn.disabled = true; uploadBtn.style.display = 'block'; uploadBtn.textContent = 'Upload'; uploadBtn.classList.remove('optimize'); document.getElementById('startConversionBtn').style.display = 'none'; document.getElementById('progress-container').style.display = 'none'; document.getElementById('progress-fill').style.width = '0%'; document.getElementById('progress-fill').style.backgroundColor = '#27ae60'; const convertOptions = document.getElementById('convertOptions'); if (convertOptions) convertOptions.style.display = 'none'; document.getElementById('convertInfo').style.display = 'none'; document.getElementById('convertWarning').style.display = 'none'; // Clear image picker cache and reset layout epubImagesCache = []; imageStates = {}; autoCropProtectedPaths = new Set(); document.getElementById('imagePickerSection').style.display = 'none'; const imageGrid = document.getElementById('imageGrid'); if (imageGrid) imageGrid.innerHTML = ''; document.querySelector('#uploadModal .modal').classList.remove('picker-mode'); document.getElementById('pickerColumns').classList.remove('picker-active'); // Hide log section if (logSection) logSection.classList.remove('visible'); // Reset advanced options const advancedSettingsContent = document.getElementById('advancedSettingsContent'); if (advancedSettingsContent) advancedSettingsContent.classList.remove('visible'); document.getElementById('advancedOptionsArrow').classList.remove('expanded'); const advancedOptionsToggle = document.getElementById('advancedOptionsToggle'); if (advancedOptionsToggle) { advancedOptionsToggle.style.opacity = '0.5'; advancedOptionsToggle.style.pointerEvents = 'none'; } applyUploadSettings(); }
function updateBatchModeUI(isBatch) { const rotationRow = document.getElementById('rotationSettingRow'); const overlapRow = document.getElementById('overlapSettingRow'); if (rotationRow) rotationRow.style.display = isBatch ? 'none' : ''; if (overlapRow) overlapRow.style.display = isBatch ? 'none' : ''; }
function toggleConvertOptions() { const checked = document.getElementById('convertBeforeUpload').checked; const uploadBtn = document.getElementById('uploadBtn'); document.getElementById('convertWarning').style.display = checked ? 'block' : 'none'; document.getElementById('convertInfo').style.display = checked ? 'block' : 'none'; // Update button text and style if (checked) { uploadBtn.textContent = 'Optimize & Upload'; uploadBtn.classList.add('optimize'); } else { uploadBtn.textContent = 'Upload'; uploadBtn.classList.remove('optimize'); // Clear image picker when unchecking clearImagePicker(); } // Reset advanced options when toggling off if (!checked) { const advancedSettingsContent = document.getElementById('advancedSettingsContent'); if (advancedSettingsContent) advancedSettingsContent.classList.remove('visible'); document.getElementById('advancedOptionsArrow').classList.remove('expanded'); } // Disable/enable advanced mode toggle const advancedOptionsToggle = document.getElementById('advancedOptionsToggle'); if (advancedOptionsToggle) { advancedOptionsToggle.style.opacity = checked ? '1' : '0.5'; advancedOptionsToggle.style.pointerEvents = checked ? 'auto' : 'none'; } updateUploadSettingsPersistence(); }
function toggleAdvancedOptions() { // Check if advanced options toggle is enabled (optimize EPUB must be checked) const convertEnabled = document.getElementById('convertBeforeUpload').checked; if (!convertEnabled) { return; // Don't toggle if optimization is disabled }
const content = document.getElementById('advancedSettingsContent'); const arrow = document.getElementById('advancedOptionsArrow'); const isExpanding = !content.classList.contains('visible'); content.classList.toggle('visible'); arrow.classList.toggle('expanded'); // When expanding, show image picker if an EPUB is selected if (isExpanding) { const fileInput = document.getElementById('fileInput'); const files = fileInput.files; if (files.length === 1 && files[0].name.toLowerCase().endsWith('.epub')) { showImagePicker(files[0]).catch(err => console.error('Image picker error:', err)); } } else { // When collapsing, hide the picker and startConversionBtn document.getElementById('imagePickerSection').style.display = 'none'; document.getElementById('startConversionBtn').style.display = 'none'; document.getElementById('uploadBtn').style.display = 'block'; document.getElementById('uploadBtn').disabled = false; document.querySelector('#uploadModal .modal').classList.remove('picker-mode'); document.getElementById('pickerColumns').classList.remove('picker-active'); } }
function setQualityPreset(value) { document.getElementById('qualitySlider').value = value; document.getElementById('qualityInput').value = value; document.querySelectorAll('.quality-preset').forEach(btn => { btn.classList.toggle('active', parseInt(btn.dataset.value, 10) === value); }); updateQualitySettings(); }
function updateQualitySettings() { const quality = document.getElementById('qualitySlider').value; const autoCropToggle = document.getElementById('autoCropToggle');
// Update displays (if element exists) const qualityDisplay = document.getElementById('qualityDisplaySimple'); if (qualityDisplay) { qualityDisplay.textContent = '📦 ' + quality + '% JPEG'; }
// Update converter variables (used by processImage and applyGrayscale) JPEG_QUALITY = parseInt(quality, 10); ENABLE_GRAYSCALE = true; // Always grayscale for e-ink ENABLE_AUTO_CROP = autoCropToggle ? autoCropToggle.checked : false; if (isImagePickerVisible()) { renderImageGrid(); } updateUploadSettingsPersistence(); }
function setHandedness(value) { HANDEDNESS = value; // Update UI document.getElementById('rotationCW').classList.remove('active'); document.getElementById('rotationCCW').classList.remove('active'); document.getElementById(value === 'right' ? 'rotationCW' : 'rotationCCW').classList.add('active'); // Re-render grid to update rotation arrows if (isImagePickerVisible()) { renderImageGrid(); } updateUploadSettingsPersistence(); }
function setOverlap(value) { OVERLAP_PERCENT = value; // Update UI document.querySelectorAll('.overlap-btn').forEach(btn => { btn.classList.toggle('active', parseInt(btn.dataset.value) === value); }); updateUploadSettingsPersistence(); }
function isImagePickerVisible() { const pickerSection = document.getElementById('imagePickerSection'); return pickerSection && pickerSection.style.display !== 'none'; }
function loadPreviewImage(dataUrl) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error('Preview image load failed')); img.src = dataUrl; }); }
async function createAutoCropPreview(dataUrl, imagePath, width, height) { const img = await loadPreviewImage(dataUrl); const cropped = createAutoCroppedCanvas(img, imagePath, width, height, true); if (!cropped.crop) return null;
const maxPreviewW = 220; const maxPreviewH = 160; const scale = Math.min(maxPreviewW / cropped.width, maxPreviewH / cropped.height, 1); const previewW = Math.max(1, Math.round(cropped.width * scale)); const previewH = Math.max(1, Math.round(cropped.height * scale)); const previewCanvas = document.createElement('canvas'); previewCanvas.width = previewW; previewCanvas.height = previewH; const previewCtx = previewCanvas.getContext('2d'); previewCtx.imageSmoothingEnabled = true; previewCtx.imageSmoothingQuality = 'high'; previewCtx.fillStyle = '#FFF'; previewCtx.fillRect(0, 0, previewW, previewH); previewCtx.drawImage(cropped.canvas, 0, 0, previewW, previewH);
return { dataUrl: previewCanvas.toDataURL('image/jpeg', 0.82), width: cropped.width, height: cropped.height }; }
// ============================================================================ // Image Picker Functions // ============================================================================
/** * Extract images from EPUB for preview * Returns array of {path, name, dataUrl, width, height} */ async function extractImagesForPreview(file) { const zip = await JSZip.loadAsync(file); const imageExtensions = ['.png', '.gif', '.webp', '.bmp', '.jpg', '.jpeg'];
// Collect all image paths first const allImages = []; for (const [path, fileObj] of Object.entries(zip.files)) { if (fileObj.dir) continue; const ext = path.substring(path.lastIndexOf('.')).toLowerCase(); if (imageExtensions.includes(ext)) { allImages.push(path); } }
// Try to get images in reading order from OPF spine let orderedImages = []; let coverImagePath = null; // Track cover image try { // Find OPF file let opfPath = null; zip.forEach(p => { if (p.toLowerCase().endsWith('.opf')) opfPath = p; });
if (opfPath) { const opfContent = await zip.files[opfPath].async('string'); const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/')) : '';
// Detect cover image from OPF let coverId = null; let m; // Try 1: properties="cover-image" if (m = opfContent.match(/<item[^>]+id=["']([^"']+)["'][^>]+properties="[^"]*cover-image[^"]*"/)) coverId = m[1]; if (!coverId && (m = opfContent.match(/<item[^>]+properties="[^"]*cover-image[^"]*"[^>]+id=["']([^"']+)["']/))) coverId = m[1]; // Try 2: meta name="cover" content="id" if (!coverId && (m = opfContent.match(/<meta\s+name=["']cover["']\s+content=["']([^"']+)["']/))) coverId = m[1]; if (!coverId && (m = opfContent.match(/<meta\s+content=["']([^"']+)["']\s+name=["']cover["']/))) coverId = m[1];
// Parse manifest to get id -> href mapping const manifest = {}; const manifestRegex = /<item[^>]+id=["']([^"']+)["'][^>]+href=["']([^"']+)["'][^>]*>/gi; let match; while ((match = manifestRegex.exec(opfContent)) !== null) { const id = match[1]; const href = match[2]; const fullPath = opfDir ? opfDir + '/' + href : href; manifest[id] = fullPath; if (id === coverId) coverImagePath = fullPath; } // Also check reversed attribute order const manifestRegex2 = /<item[^>]+href=["']([^"']+)["'][^>]+id=["']([^"']+)["'][^>]*>/gi; while ((match = manifestRegex2.exec(opfContent)) !== null) { const href = match[1]; const id = match[2]; const fullPath = opfDir ? opfDir + '/' + href : href; manifest[id] = fullPath; if (id === coverId) coverImagePath = fullPath; }
// Cover-page reconciliation — if the cover XHTML references a different // but byte-identical image, prefer the one actually displayed on the page. if (coverImagePath) { try { let coverXhtmlPath = null; const guideM = opfContent.match(/<(?:\w+:)?reference[^>]+type=["']cover["'][^>]+href=["']([^"']+)["']/i) || opfContent.match(/<(?:\w+:)?reference[^>]+href=["']([^"']+)["'][^>]+type=["']cover["']/i); if (guideM) { coverXhtmlPath = opfDir ? opfDir + '/' + decodeHref(guideM[1]) : decodeHref(guideM[1]); } if (!coverXhtmlPath) { const spineM = opfContent.match(/<(?:\w+:)?itemref[^>]+idref=["']([^"']+)["']/i); if (spineM && manifest[spineM[1]]) coverXhtmlPath = manifest[spineM[1]]; } if (coverXhtmlPath && zip.files[coverXhtmlPath]) { const coverXhtml = await zip.files[coverXhtmlPath].async('string'); const imgM = coverXhtml.match(/(?:src|xlink:href)=["']([^"']+)["']/i); if (imgM) { const href = imgM[1]; const xDir = coverXhtmlPath.includes('/') ? coverXhtmlPath.substring(0, coverXhtmlPath.lastIndexOf('/')) : ''; let pageImgPath = href.startsWith('../') ? xDir.split('/').slice(0, -1).join('/') + '/' + href.substring(3) : href.startsWith('/') ? href.substring(1) : xDir ? xDir + '/' + href : href; pageImgPath = pageImgPath.replace(/\/+/g, '/'); for (const realPath of allImages) { if (realPath === pageImgPath || realPath.endsWith('/' + href) || realPath.endsWith(href)) { pageImgPath = realPath; break; } } if (pageImgPath !== coverImagePath && allImages.includes(pageImgPath) && zip.files[pageImgPath]) { const coverData = await zip.files[coverImagePath].async('arraybuffer'); const pageData = await zip.files[pageImgPath].async('arraybuffer'); if (coverData.byteLength === pageData.byteLength) { const a = new Uint8Array(coverData); const b = new Uint8Array(pageData); let identical = true; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) { identical = false; break; } } if (identical) coverImagePath = pageImgPath; } } } } } catch (e) { /* non-critical */ } }
// Parse spine to get reading order const spineOrder = []; const spineRegex = /<itemref[^>]+idref=["']([^"']+)["'][^>]*>/gi; while ((match = spineRegex.exec(opfContent)) !== null) { const idref = match[1]; if (manifest[idref]) spineOrder.push(manifest[idref]); }
// For each spine item (XHTML), extract images in order const seenImages = new Set(); for (const xhtmlPath of spineOrder) { if (!zip.files[xhtmlPath]) continue; const xhtmlContent = await zip.files[xhtmlPath].async('string'); const xhtmlDir = xhtmlPath.includes('/') ? xhtmlPath.substring(0, xhtmlPath.lastIndexOf('/')) : '';
// Find all image references const imgRegex = /(?:src|xlink:href)=["']([^"']+)["']/gi; while ((match = imgRegex.exec(xhtmlContent)) !== null) { let imgHref = match[1]; // Skip non-image references if (!imageExtensions.some(ext => imgHref.toLowerCase().endsWith(ext))) continue;
// Resolve relative path let imgPath; if (imgHref.startsWith('../')) { // Go up from xhtmlDir const parts = xhtmlDir.split('/'); parts.pop(); imgPath = parts.join('/') + '/' + imgHref.substring(3); } else if (imgHref.startsWith('/')) { imgPath = imgHref.substring(1); } else { imgPath = xhtmlDir ? xhtmlDir + '/' + imgHref : imgHref; } // Normalize path imgPath = imgPath.replace(/\/+/g, '/');
// Check if this is actually an image in our list for (const realPath of allImages) { if (realPath === imgPath || realPath.endsWith('/' + imgHref) || realPath.endsWith(imgHref)) { if (!seenImages.has(realPath)) { seenImages.add(realPath); orderedImages.push(realPath); } break; } } } }
// Add any remaining images that weren't in XHTML files (e.g., unused images) for (const imgPath of allImages) { if (!seenImages.has(imgPath)) { orderedImages.push(imgPath); } } } } catch (e) { console.warn('Failed to parse reading order, using default:', e); }
// Fallback to alphabetical if parsing failed if (orderedImages.length === 0) { orderedImages = [...allImages].sort(); }
// Load image data in order const images = []; for (const path of orderedImages) { const data = await zip.files[path].async('arraybuffer'); const blob = new Blob([data]); const dataUrl = URL.createObjectURL(blob);
// Get dimensions const dims = await getImageDimensions(data); // Check if this is the cover image const isCover = (path === coverImagePath) || path.toLowerCase().includes('cover') && images.length === 0; // Check if this is a separator/ornament (skip cover check) const filename = path.split('/').pop(); let isSeparator = false; if (!isCover) { try { isSeparator = await isSeparatorImage(dataUrl, dims.width, dims.height, filename); } catch (e) { console.warn('Separator check failed for', filename, e); } } // Tiny images (<200x200) are locked like separators const isTiny = (dims.width < 200 && dims.height < 200); const isLockedImage = isCover || isSeparator || isTiny; let autoCropPreview = null; if (!isLockedImage) { try { autoCropPreview = await createAutoCropPreview(dataUrl, path, dims.width, dims.height); } catch (e) { console.warn('Auto-crop preview failed for', path, e); } } // Images that fit screen can only rotate, not split const fitsScreen = (dims.width <= MAX_WIDTH && dims.height <= MAX_HEIGHT);
// Split capability - no upscaling allowed // H-Split scales width to MAX_HEIGHT (long edge), so needs width >= MAX_HEIGHT // V-Split scales height to MAX_HEIGHT, so needs height >= MAX_HEIGHT const canHSplit = dims.width >= MAX_HEIGHT; const canVSplit = dims.height >= MAX_HEIGHT;
images.push({ path: path, name: filename, dataUrl: dataUrl, autoCropPreview: autoCropPreview, width: dims.width, height: dims.height, isCover: isCover, isSeparator: isSeparator || isTiny, fitsScreen: fitsScreen, canHSplit: canHSplit, canVSplit: canVSplit }); }
return images; }
/** * Get image dimensions from array buffer */ function getImageDimensions(data) { return new Promise((resolve, reject) => { const url = URL.createObjectURL(new Blob([data])); const img = new Image(); img.onload = () => { URL.revokeObjectURL(url); resolve({ width: img.width, height: img.height }); }; img.onerror = () => { URL.revokeObjectURL(url); resolve({ width: 0, height: 0 }); }; img.src = url; }); }
/** * Check if image is a separator/ornament * Criteria: small size AND (filename match OR symmetric OR extreme aspect ratio) */ async function isSeparatorImage(dataUrl, width, height, filename) { const MAX_DIMENSION = 150; const SYMMETRY_THRESHOLD = 0.85; // First check: must be small in at least one dimension const isSmall = (height < MAX_DIMENSION || width < MAX_DIMENSION); if (!isSmall) return false; // Filename hints (instant match if small + named correctly) const separatorNames = ['separator', 'divider', 'ornament', 'break', 'flourish', 'scene', 'divid', 'decor']; const lowerName = filename.toLowerCase(); if (separatorNames.some(n => lowerName.includes(n))) return true; // Extreme aspect ratio check (>10:1 or <1:10) - these are definitely separators/lines const aspectRatio = width / height; if (aspectRatio > 10 || aspectRatio < 0.1) return true; // Symmetry check (skip for very thin images - too few pixels) if (width < 10 || height < 10) return true; // Very small = separator try { const isSymmetric = await checkHorizontalSymmetry(dataUrl, width, height, SYMMETRY_THRESHOLD); return isSymmetric; } catch (e) { console.warn('Symmetry check failed:', e); return false; } }
/** * Check horizontal symmetry by comparing left and right halves */ function checkHorizontalSymmetry(dataUrl, width, height, threshold) { return new Promise((resolve) => { const img = new Image(); img.onload = () => { // Use small canvas for performance (max 100px wide) const scale = Math.min(1, 100 / width); const w = Math.max(2, Math.floor(width * scale)); // Minimum 2px const h = Math.max(1, Math.floor(height * scale)); // Minimum 1px const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); // Draw scaled image ctx.drawImage(img, 0, 0, w, h); const imageData = ctx.getImageData(0, 0, w, h); const pixels = imageData.data; // Compare left half with flipped right half const halfW = Math.floor(w / 2); let matchingPixels = 0; let totalPixels = 0; for (let y = 0; y < h; y++) { for (let x = 0; x < halfW; x++) { const leftIdx = (y * w + x) * 4; const rightIdx = (y * w + (w - 1 - x)) * 4; // Compare RGB (ignore alpha) const rDiff = Math.abs(pixels[leftIdx] - pixels[rightIdx]); const gDiff = Math.abs(pixels[leftIdx + 1] - pixels[rightIdx + 1]); const bDiff = Math.abs(pixels[leftIdx + 2] - pixels[rightIdx + 2]); // Allow some tolerance for JPEG artifacts (threshold of 30) if (rDiff < 30 && gDiff < 30 && bDiff < 30) { matchingPixels++; } totalPixels++; } } const symmetryScore = matchingPixels / totalPixels; resolve(symmetryScore >= threshold); }; img.onerror = () => resolve(false); img.src = dataUrl; }); }
/** * Show image picker after EPUB file selection */ async function showImagePicker(file) { // Check if JSZip is available if (typeof JSZip === 'undefined') { console.error('JSZip not loaded'); alert('JSZip library not available. Conversion will proceed without image picker.'); startConversionWithImageStates(); return; }
const pickerSection = document.getElementById('imagePickerSection'); const imageGrid = document.getElementById('imageGrid'); const countDisplay = document.getElementById('imagePickerCount');
// Reset state imageStates = {}; epubImagesCache = []; autoCropProtectedPaths = new Set(); pendingConversionFile = file;
// Extract images try { const images = await extractImagesForPreview(file); epubImagesCache = images; autoCropProtectedPaths = new Set( images .filter(img => img.isCover || img.isSeparator) .map(img => img.path) );
// Initialize all states to 0 (Normal) images.forEach(img => { imageStates[img.path] = 0; });
// Build UI renderImageGrid();
// Count images - covers and separators are locked const coverCount = images.filter(img => img.isCover).length; const separatorCount = images.filter(img => img.isSeparator).length; const lockedCount = coverCount + separatorCount; const selectableCount = images.length - lockedCount; if (lockedCount > 0) { const lockedParts = []; if (coverCount > 0) lockedParts.push(`${coverCount} cover`); if (separatorCount > 0) lockedParts.push(`${separatorCount} separator${separatorCount !== 1 ? 's' : ''}`); countDisplay.textContent = `${images.length} images (${selectableCount} selectable, ${lockedParts.join(', ')})`; } else { countDisplay.textContent = `${images.length} image${images.length !== 1 ? 's' : ''} (all selectable)`; }
// Show picker, hide upload button, show start conversion button pickerSection.style.display = 'block'; document.getElementById('uploadBtn').style.display = 'none'; document.getElementById('startConversionBtn').style.display = 'block'; // Enable two-column layout document.querySelector('#uploadModal .modal').classList.add('picker-mode'); document.getElementById('pickerColumns').classList.add('picker-active');
} catch (error) { console.error('Failed to extract images:', error); alert('Failed to preview images: ' + error.message + '\n\nConversion will proceed normally.'); // Fallback: start conversion directly startConversionWithImageStates(); } }
/** * Render the image grid with current states */ function renderImageGrid() { const imageGrid = document.getElementById('imageGrid'); imageGrid.innerHTML = '';
const stateLabels = ['', 'H-Split', 'V-Split', 'Rotate']; const stateClasses = ['state-0', 'state-1', 'state-2', 'state-3'];
epubImagesCache.forEach(img => { // Cover images and separators are always locked (no splitting/rotation) const isCover = img.isCover; const isSeparator = img.isSeparator; const state = imageStates[img.path] || 0;
const item = document.createElement('div');
if (isCover) { // Cover image - locked, cannot be split item.className = 'image-item cover-locked'; item.title = `${img.width}×${img.height} - Cover image (locked)`; item.innerHTML = ` <span class="image-state-badge cover-badge">🔒</span> <img src="${img.dataUrl}" alt="${img.name}" loading="lazy"> <div class="image-name">${img.name}</div> `; } else if (isSeparator) { // Separator/ornament - locked, cannot be split item.className = 'image-item separator-locked'; item.title = `${img.width}×${img.height} - Separator (locked)`; item.innerHTML = ` <span class="image-state-badge separator-badge">✦</span> <img src="${img.dataUrl}" alt="${img.name}" loading="lazy"> <div class="image-name">${img.name}</div> `; } else { // All other images are selectable - all modes allowed // Determine which overlay to show based on state const showRotation = (state === 1 || state === 3); // H-Split or Rotate const showSplitLines = (state === 1 || state === 2); // H-Split or V-Split const rotateClass = showRotation ? (HANDEDNESS === 'right' ? 'rotate-cw' : 'rotate-ccw') : ''; // Calculate actual number of parts for split preview let numParts = 1; if (showSplitLines) { let finalWidth; if (state === 1) { // H-Split: scale width to MAX_HEIGHT, rotate, then check width const scaledH = Math.round(img.height * (MAX_HEIGHT / img.width)); finalWidth = scaledH; // After rotation, height becomes width } else { // V-Split: scale height to MAX_HEIGHT, then check width finalWidth = Math.round(img.width * (MAX_HEIGHT / img.height)); } if (finalWidth > MAX_WIDTH) { const minOverlapPx = Math.round(MAX_WIDTH * (OVERLAP_PERCENT / 100)); const maxStep = MAX_WIDTH - minOverlapPx; numParts = Math.ceil((finalWidth - minOverlapPx) / maxStep); if (numParts < 2) numParts = 2; } } // Generate split line elements (numParts - 1 lines at evenly distributed positions) let splitLinesHtml = ''; if (showSplitLines && numParts > 1) { const lines = []; const splitClass = state === 1 ? 'split-h' : 'split-v'; for (let i = 1; i < numParts; i++) { const pos = (i / numParts) * 100; lines.push(`<div class="split-line ${splitClass}" style="left:${pos}%"></div>`); } splitLinesHtml = `<div class="split-lines">${lines.join('')}</div>`; } // Build tooltip const stateText = stateLabels[state] || 'Normal'; const partsText = numParts > 1 ? ` (${numParts} parts)` : ''; const showCropPreview = ENABLE_AUTO_CROP && img.autoCropPreview; const previewSrc = showCropPreview ? img.autoCropPreview.dataUrl : img.dataUrl; const cropText = showCropPreview ? ` | Auto-crop: ${img.autoCropPreview.width}×${img.autoCropPreview.height}` : ''; item.className = `image-item ${stateClasses[state]} ${rotateClass} ${showCropPreview ? 'crop-preview' : ''}`.trim(); item.onclick = () => cycleImageState(img.path); item.title = `${img.width}×${img.height} - ${stateText}${partsText}${cropText}`; item.innerHTML = ` <span class="image-state-badge">${showCropPreview ? '✂' : (stateLabels[state] || '•')}</span> <div class="image-preview-overlay"> ${splitLinesHtml} </div> <img src="${previewSrc}" alt="${img.name}" loading="lazy"> <div class="image-name">${img.name}</div> `; }
imageGrid.appendChild(item); }); }
/** * Cycle state for a single image * All non-locked images: 0 -> 1 -> 2 -> 3 -> 0 */ function cycleImageState(imagePath) { const currentState = imageStates[imagePath] || 0; imageStates[imagePath] = (currentState + 1) % 4; renderImageGrid(); }
/** * Apply state to all eligible images based on smart rules * 0 = Normal (all selectable images) * 1 = H-Split (landscapes that canHSplit) * 2 = V-Split (all images that canVSplit - portraits AND landscapes) * 3 = Rotate (landscapes that don't fit screen) */ function applyStateToAll(state) { epubImagesCache.forEach(img => { // Skip locked images if (img.isCover || img.isSeparator) return; const canHSplit = img.canHSplit && !img.fitsScreen; const canVSplit = img.canVSplit && !img.fitsScreen; const isLandscape = img.width > img.height; if (state === 0) { // Normal - applies to all selectable imageStates[img.path] = 0; } else if (state === 1) { // H-Split - all landscapes that can H-Split if (isLandscape && canHSplit) { imageStates[img.path] = 1; } } else if (state === 2) { // V-Split - all images that can V-Split (portrait and landscape) if (canVSplit) { imageStates[img.path] = 2; } } else if (state === 3) { // Rotate - landscapes that exceed screen if (isLandscape && !img.fitsScreen) { imageStates[img.path] = 3; } } }); renderImageGrid(); }
/** * Start conversion with configured image states */ function startConversionWithImageStates() { const pickerSection = document.getElementById('imagePickerSection'); const uploadBtn = document.getElementById('uploadBtn'); const startConversionBtn = document.getElementById('startConversionBtn');
// Hide picker and start conversion button, remove two-column layout pickerSection.style.display = 'none'; startConversionBtn.style.display = 'none'; document.querySelector('#uploadModal .modal').classList.remove('picker-mode'); document.getElementById('pickerColumns').classList.remove('picker-active');
// Show upload button and trigger upload uploadBtn.style.display = 'block'; uploadBtn.disabled = false; uploadFile(); }
/** * Get processing state for an image path * Returns 0 (Normal), 1 (H-Split), 2 (V-Split), or 3 (Rotate) */ function getImageState(imagePath) { return imageStates[imagePath] || 0; }
/** * Get state label for logging */ function getStateLabel(state) { const labels = ['Normal', 'H-Split', 'V-Split', 'Rotate']; return labels[state] || 'Normal'; }
// Initialize quality settings handlers document.addEventListener('DOMContentLoaded', function() { // Delegated so it survives file-table re-renders. Names and paths are // user-controlled text, so they travel as data attributes rather than // being interpolated into inline onclick strings. document.getElementById('file-table').addEventListener('click', function(e) { const link = e.target.closest('.image-preview-link'); if (link) { e.preventDefault(); openImagePreview(link.getAttribute('href'), link.textContent); return; } const btn = e.target.closest('[data-action]'); if (!btn) return; const name = btn.dataset.name; const path = btn.dataset.path; if (btn.dataset.action === 'move') { openMoveModal(name, path); } else if (btn.dataset.action === 'rename') { openRenameModal(name, path); } else if (btn.dataset.action === 'delete') { openDeleteModal(name, path, btn.dataset.folder === 'true'); } });
const qualitySlider = document.getElementById('qualitySlider'); const qualityInput = document.getElementById('qualityInput');
// Initialize advanced mode toggle state based on Optimize EPUB checkbox const convertCheckbox = document.getElementById('convertBeforeUpload'); const advancedToggle = document.getElementById('advancedOptionsToggle'); if (convertCheckbox && advancedToggle) { const isOptimizeEnabled = convertCheckbox.checked; advancedToggle.style.opacity = isOptimizeEnabled ? '1' : '0.5'; advancedToggle.style.pointerEvents = isOptimizeEnabled ? 'auto' : 'none'; }
if (qualitySlider && qualityInput) { // Initialize converter variables with UI default values suppressUploadSettingsSave = true; try { updateQualitySettings(); } finally { suppressUploadSettingsSave = false; }
// Deselect all presets when slider is manually changed const deselectPresets = function() { document.querySelectorAll('.quality-preset').forEach(btn => { btn.classList.remove('active'); }); };
qualitySlider.oninput = function() { qualityInput.value = this.value; deselectPresets(); updateQualitySettings(); }; qualityInput.onchange = function() { let v = Math.max(1, Math.min(95, parseInt(this.value, 10) || 85)); this.value = v; qualitySlider.value = v; deselectPresets(); updateQualitySettings(); }; qualityInput.onblur = function() { this.value = qualitySlider.value; }; } });
function openFolderModal() { document.getElementById('folderPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath; document.getElementById('folderModal').classList.add('open'); document.getElementById('folderName').value = ''; setTimeout(() => document.getElementById('folderName').focus(), 50); document.getElementById('folderName').onkeydown = (e) => { if (e.key === 'Enter' && e.target.value.trim()) createFolder(); }; }
function closeFolderModal() { document.getElementById('folderModal').classList.remove('open'); }
// Toggle select-all checkbox function toggleSelectAll(master) { const checked = master.checked; document.querySelectorAll('.select-item').forEach(cb => { cb.checked = checked; }); }
function getSelectedItems() { const items = []; document.querySelectorAll('.select-item:checked').forEach(cb => { items.push({ name: cb.dataset.name || decodeURIComponent(cb.dataset.path).split('/').pop(), path: decodeURIComponent(cb.dataset.path), isFolder: cb.dataset.type === 'folder' }); }); return items; }
// Open delete modal for currently selected checkboxes function openDeleteSelectedModal() { const items = getSelectedItems(); if (items.length === 0) { alert('Please select at least one item to delete.'); return; } openDeleteModalForItems(items); }
// Open delete modal for a single item (keeps backwards compatibility with per-row delete button) function openDeleteModal(name, path, isFolder) { openDeleteModalForItems([{ name: name, path: path, isFolder: !!isFolder }]); }
let deleteItemsGlobal = [];
function openDeleteModalForItems(items) { deleteItemsGlobal = items; const listEl = document.getElementById('deleteItemList'); listEl.innerHTML = ''; items.forEach(it => { const div = document.createElement('div'); div.style.marginBottom = '6px'; div.textContent = (it.isFolder ? '📁 ' : '📄 ') + it.path; listEl.appendChild(div); }); document.getElementById('deleteModal').classList.add('open'); }
function closeDeleteModal() { document.getElementById('deleteModal').classList.remove('open'); }
function confirmDelete() { if (!deleteItemsGlobal || deleteItemsGlobal.length === 0) { closeDeleteModal(); return; }
const paths = deleteItemsGlobal.map(it => { // Ensure path starts with / let p = it.path; if (!p.startsWith('/')) p = '/' + p; return p; });
const body = 'paths=' + encodeURIComponent(JSON.stringify(paths)); fetch('/delete', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body }).then(async res => { if (res.ok) { window.location.reload(); } else { const text = await res.text(); alert('Failed to delete: ' + text); closeDeleteModal(); } }).catch(() => { alert('Failed to delete - network error'); closeDeleteModal(); }); }
// Helper to clear image picker state function clearImagePicker() { epubImagesCache = []; imageStates = {}; autoCropProtectedPaths = new Set(); const imageGrid = document.getElementById('imageGrid'); if (imageGrid) imageGrid.innerHTML = ''; const pickerSection = document.getElementById('imagePickerSection'); if (pickerSection) pickerSection.style.display = 'none'; // Reset two-column layout document.querySelector('#uploadModal .modal')?.classList.remove('picker-mode'); document.getElementById('pickerColumns')?.classList.remove('picker-active'); // Hide start conversion, show upload const startBtn = document.getElementById('startConversionBtn'); if (startBtn) startBtn.style.display = 'none'; const uploadBtn = document.getElementById('uploadBtn'); if (uploadBtn) uploadBtn.style.display = 'block'; }
// Set up file input click listener once (function setupFileInputListener() { const fileInput = document.getElementById('fileInput'); if (!fileInput) return; fileInput.addEventListener('click', function() { // Small delay to allow browser to process click before checking files setTimeout(() => { if (fileInput.files.length === 0) { clearImagePicker(); document.getElementById('uploadBtn').disabled = true; } }, 10); });
// Drag-and-drop: route dropped files through the existing file input so the // normal validateFile() pipeline (EPUB optimize options, batch mode, upload) // runs unchanged. Assigning input.files via DataTransfer is supported in all // current Chromium/Firefox/Safari, which is what the device serves the page to. const dropZone = document.getElementById('dropZone'); if (dropZone) { // dragenter/dragleave fire for child elements too; a counter avoids the // highlight flickering as the cursor moves over the hint text or input. let dragDepth = 0;
// An in-flight transfer can't be interrupted, so the zone is inert while // uploading: no picker, no hover highlight — matching the disabled upload // button and the drop handler's guard below. const uploadBusy = () => typeof isUploadInProgress !== 'undefined' && isUploadInProgress;
// "click to browse" should work anywhere in the zone, not just on the // native button. The hint has pointer-events:none so its clicks retarget // to the zone; clicks on the native button retarget to fileInput (shadow // DOM) and are skipped here so the picker isn't opened twice. dropZone.addEventListener('click', function(e) { if (uploadBusy()) return; if (e.target !== fileInput) fileInput.click(); });
dropZone.addEventListener('dragenter', function(e) { e.preventDefault(); dragDepth++; if (!uploadBusy()) dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragover', function(e) { // Required so the browser doesn't navigate to / open the dropped file. e.preventDefault(); });
dropZone.addEventListener('dragleave', function(e) { e.preventDefault(); dragDepth = Math.max(0, dragDepth - 1); if (dragDepth === 0) dropZone.classList.remove('dragover'); });
dropZone.addEventListener('drop', function(e) { e.preventDefault(); dragDepth = 0; dropZone.classList.remove('dragover');
// Don't let a drop disrupt an in-flight transfer. if (uploadBusy()) return;
const dropped = e.dataTransfer && e.dataTransfer.files; if (!dropped || dropped.length === 0) return;
// Replace the current selection, matching native file-picker semantics. const dt = new DataTransfer(); for (const file of dropped) dt.items.add(file); fileInput.files = dt.files;
validateFile(); }); } })();
function validateFile() { const fileInput = document.getElementById('fileInput'); const uploadBtn = document.getElementById('uploadBtn'); const files = fileInput.files; const convertOptions = document.getElementById('convertOptions'); fileInput.classList.toggle('has-files', files.length > 0);
// Show convert options only when at least one selected file is an EPUB. const hasEpub = Array.from(files).some(f => f.name.toLowerCase().endsWith('.epub')); if (files.length > 0 && hasEpub) { convertOptions.style.display = 'block'; toggleConvertOptions(); } else { convertOptions.style.display = 'none'; uploadBtn.textContent = 'Upload'; uploadBtn.classList.remove('optimize'); document.getElementById('convertInfo').style.display = 'none'; document.getElementById('convertWarning').style.display = 'none'; if (files.length === 0) clearImagePicker(); }
if (files.length > 0) { // If advanced settings is expanded and single EPUB, show image picker const advancedContent = document.getElementById('advancedSettingsContent'); const convertEnabled = document.getElementById('convertBeforeUpload').checked; if (advancedContent.classList.contains('visible') && files.length === 1 && convertEnabled && files[0].name.toLowerCase().endsWith('.epub')) { showImagePicker(files[0]).catch(err => console.error('Image picker error:', err)); } else { // New selection no longer matches single-EPUB-with-advanced-expanded — // tear down any picker from a previous file so the grid and picker-mode // layout don't linger. clearImagePicker is idempotent. clearImagePicker(); }
// If multiple files with conversion, inform user about batch mode if (files.length > 1 && convertEnabled) { const epubCount = Array.from(files).filter(f => f.name.toLowerCase().endsWith('.epub')).length; if (epubCount > 0) { console.log(`Batch mode: ${epubCount} EPUB(s) will use auto settings`); } }
updateBatchModeUI(files.length > 1); uploadBtn.disabled = isUploadInProgress; } else { updateBatchModeUI(false); uploadBtn.disabled = true; } }
let failedUploadsGlobal = [];let wsConnection = null;let isUploadInProgress = false; // Prevent modal close during upload/conversionlet operationCancelled = false; // Set by Cancel to stop conversion loops and upload async handlerslet uploadGeneration = 0; // Incremented each uploadFile() call; guards stale restoreAfterCancel()let currentUploadWs = null; // Active WebSocket reference for external abortlet currentUploadXhr = null; // Active XHR reference for external abort// On-device HTTP uses port 80 and WebSocket uses 81. The simulator maps the// same adjacent-port contract to an unprivileged host pair such as 8080/8081.const HTTP_PORT = Number(window.location.port || 80);const WS_PORT = HTTP_PORT + 1;const WS_CHUNK_SIZE = 4096; // 4KB chunks - smaller for ESP32 stability
// ============================================================================// EPUB Image Conversion Functions (from Baseline JPEG Converter)// ============================================================================
// Device profiles (short edge × long edge in portrait orientation)const DEVICE_PROFILES = { X4: { width: 480, height: 800, label: 'X4' }, X3: { width: 528, height: 792, label: 'X3' },};
// Default conversion settingsconst DEFAULT_DEVICE = 'X4';const DEFAULT_MAX_WIDTH = DEVICE_PROFILES[DEFAULT_DEVICE].width;const DEFAULT_MAX_HEIGHT = DEVICE_PROFILES[DEFAULT_DEVICE].height;const DEFAULT_JPEG_QUALITY = 85;const DEFAULT_ENABLE_GRAYSCALE = true;const DEFAULT_ENABLE_AUTO_CROP = false;// Auto-crop is opt-in because sparse title/logo pages can be zoomed aggressively.const CROP_WHITE_THRESHOLD = 245;const CROP_BACKGROUND_TOLERANCE = 28;const CROP_BACKGROUND_MAX_SPREAD = 24;const CROP_EDGE_SAMPLE_SIZE = 12;const CROP_PADDING_PX = 8;const MIN_CROP_SAVINGS_RATIO = 0.08;const MIN_COLOR_CROP_SAVINGS_RATIO = 0.20;const MIN_CROP_DIMENSION = 240;// Note: Overlap is now always centered distribution (min 5%)
// Dynamic conversion settings (updated by UI)let DEVICE_TARGET = 'auto'; // 'auto' | 'X4' | 'X3'let DETECTED_DEVICE = null; // populated from /api/statuslet ACTIVE_DEVICE = DEFAULT_DEVICE;let MAX_WIDTH = DEFAULT_MAX_WIDTH;let MAX_HEIGHT = DEFAULT_MAX_HEIGHT;let JPEG_QUALITY = DEFAULT_JPEG_QUALITY;let ENABLE_GRAYSCALE = DEFAULT_ENABLE_GRAYSCALE;let ENABLE_AUTO_CROP = DEFAULT_ENABLE_AUTO_CROP;let HANDEDNESS = 'right'; // 'right' = clockwise (right-handed), 'left' = counter-clockwise (left-handed)let OVERLAP_PERCENT = 5; // Minimum overlap percentage for splits (5%, 10%, 15%)const UPLOAD_SETTINGS_STORAGE_KEY = 'crosspoint.files.uploadSettings.v1';const DEFAULT_UPLOAD_SETTINGS = Object.freeze({ convertBeforeUpload: false, renameFromMetadata: false, quality: DEFAULT_JPEG_QUALITY, autoCrop: DEFAULT_ENABLE_AUTO_CROP, deviceTarget: 'auto', handedness: 'right', overlap: 5, exportLog: false});let suppressUploadSettingsSave = false;
function getCurrentUploadSettings() { return { convertBeforeUpload: !!document.getElementById('convertBeforeUpload')?.checked, renameFromMetadata: !!document.getElementById('renameFromMetadataToggle')?.checked, quality: parseInt(document.getElementById('qualitySlider')?.value || JPEG_QUALITY, 10), autoCrop: !!document.getElementById('autoCropToggle')?.checked, deviceTarget: DEVICE_TARGET, handedness: HANDEDNESS, overlap: OVERLAP_PERCENT, exportLog: !!document.getElementById('export-log-checkbox')?.checked };}
function applyUploadSettings(settings = {}) { const merged = { ...DEFAULT_UPLOAD_SETTINGS, ...settings }; suppressUploadSettingsSave = true; try { document.getElementById('convertBeforeUpload').checked = !!merged.convertBeforeUpload; document.getElementById('renameFromMetadataToggle').checked = !!merged.renameFromMetadata; document.getElementById('autoCropToggle').checked = !!merged.autoCrop; document.getElementById('export-log-checkbox').checked = !!merged.exportLog; document.getElementById('rememberUploadSettings').checked = !!settings.rememberSettings;
setQualityPreset(Math.max(1, Math.min(95, parseInt(merged.quality, 10) || DEFAULT_JPEG_QUALITY))); setDeviceTarget(['auto', 'X3', 'X4'].includes(merged.deviceTarget) ? merged.deviceTarget : 'auto'); setHandedness(merged.handedness === 'left' ? 'left' : 'right'); setOverlap([5, 10, 15].includes(Number(merged.overlap)) ? Number(merged.overlap) : 5); toggleConvertOptions(); } finally { suppressUploadSettingsSave = false; }}
function restoreUploadSettingsFromStorage() { try { const saved = JSON.parse(localStorage.getItem(UPLOAD_SETTINGS_STORAGE_KEY) || 'null'); applyUploadSettings(saved?.rememberSettings ? saved : undefined); } catch (e) { console.warn('Could not read remembered upload settings:', e); applyUploadSettings(); }}
function updateUploadSettingsPersistence() { if (suppressUploadSettingsSave) return; try { if (!document.getElementById('rememberUploadSettings')?.checked) { localStorage.removeItem(UPLOAD_SETTINGS_STORAGE_KEY); return; } const settings = { ...getCurrentUploadSettings(), rememberSettings: true }; localStorage.setItem(UPLOAD_SETTINGS_STORAGE_KEY, JSON.stringify(settings)); } catch (e) { console.warn('Could not save upload settings:', e); }}
// ============================================================================// Image Picker State Management// ============================================================================
let imageStates = {}; // Map: imagePath -> state (0=Normal, 1=H-Split, 2=V-Split, 3=Rotate)let epubImagesCache = []; // Cache of extracted images for previewlet autoCropProtectedPaths = new Set(); // Picker-locked covers/separators should never be auto-croppedlet pendingConversionFile = null; // File awaiting conversion after image selection
// ============================================================================// Enhanced Logging System// ============================================================================
let logStartTime = null;let conversionStats = { images: 0, splits: 0, splitParts: 0, fixes: 0, skipped: 0, errors: 0, originalSize: 0, newSize: 0 };const logSection = document.getElementById('log-section');const logContainer = document.getElementById('log-container');const exportLogCheckbox = document.getElementById('export-log-checkbox');
// CrossPoint version (fetched from API)let crosspointVersion = 'Unknown';
// Fetch version from APIasync function fetchVersion() { try { const response = await fetch('/api/status'); if (response.ok) { const data = await response.json(); crosspointVersion = data.version || 'Unknown'; if (data.device === 'X3' || data.device === 'X4') { DETECTED_DEVICE = data.device; applyDeviceTarget(); } } } catch (e) { console.error('Failed to fetch version:', e); }}
// Resolve DEVICE_TARGET ('auto' | 'X4' | 'X3') to a concrete profile and update UI.function applyDeviceTarget() { const resolved = DEVICE_TARGET === 'auto' ? (DETECTED_DEVICE || DEFAULT_DEVICE) : DEVICE_TARGET; const profile = DEVICE_PROFILES[resolved] || DEVICE_PROFILES[DEFAULT_DEVICE]; ACTIVE_DEVICE = resolved; MAX_WIDTH = profile.width; MAX_HEIGHT = profile.height;
const summary = document.getElementById('convertSizeSummary'); if (summary) { summary.textContent = `📏 Max ${profile.width}×${profile.height}px`; } document.querySelectorAll('.device-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.value === DEVICE_TARGET); }); const autoLabel = document.getElementById('deviceAutoLabel'); if (autoLabel) { autoLabel.textContent = DETECTED_DEVICE ? `Auto (${DETECTED_DEVICE})` : 'Auto'; }
// Recompute picker classification with new dimensions, then refresh grid. if (Array.isArray(epubImagesCache) && epubImagesCache.length > 0) { for (const img of epubImagesCache) { img.fitsScreen = (img.width <= MAX_WIDTH && img.height <= MAX_HEIGHT); img.canHSplit = img.width >= MAX_HEIGHT; img.canVSplit = img.height >= MAX_HEIGHT; } const pickerSection = document.getElementById('imagePickerSection'); if (pickerSection && pickerSection.style.display !== 'none' && typeof renderImageGrid === 'function') { renderImageGrid(); } }}
function setDeviceTarget(value) { DEVICE_TARGET = value; applyDeviceTarget(); updateUploadSettingsPersistence();}
// Batch logging system for multiple fileslet batchLogEntries = [];let batchStats = { filesProcessed: 0, filesSucceeded: 0, filesFailed: 0, totalImages: 0, totalSplits: 0, totalFixes: 0, totalErrors: 0, totalOriginalSize: 0, totalNewSize: 0 };let batchStartTime = null;let isBatchMode = false;
// Format bytes to human-readable size (for logging)function formatBytes(b) { if (!b) return '0 B'; const k = 1024; const s = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(b) / Math.log(k)); return (b / Math.pow(k, i)).toFixed(1) + ' ' + s[i];}
// Get elapsed timestamp since log startfunction getTimestamp() { if (!logStartTime) return '[00:00.0]'; const elapsed = (Date.now() - logStartTime) / 1000; const mins = Math.floor(elapsed / 60).toString().padStart(2, '0'); const secs = (elapsed % 60).toFixed(1).padStart(4, '0'); return `[${mins}:${secs}]`;}
// Main logging functionfunction log(message, type = '', tag = '') { const entry = document.createElement('div'); entry.className = 'log-entry ' + type;
// Timestamp const timestamp = document.createElement('span'); timestamp.className = 'log-timestamp'; timestamp.textContent = getTimestamp(); entry.appendChild(timestamp);
// Tag (if provided) if (tag) { const tagEl = document.createElement('span'); tagEl.className = 'log-tag ' + tag.toLowerCase(); tagEl.textContent = tag; entry.appendChild(tagEl); }
// Message const msg = document.createElement('span'); msg.className = 'log-message'; msg.innerHTML = message; entry.appendChild(msg);
logContainer.appendChild(entry); logContainer.scrollTop = logContainer.scrollHeight;}
// Log image processing detailsfunction logImage(name, origW, origH, origFormat, origSize, newW, newH, newSize, wasSplit = false, splitCount = 0, partsInfo = null, imageState = 0) { const saved = origSize - newSize; const savedPct = ((saved / origSize) * 100).toFixed(0); const dims = `${origW}×${origH}`; const newDims = `${newW}×${newH}`;
// Get state label and color const stateLabels = ['', 'H-Split', 'V-Split', 'Rotate']; const stateColors = ['', '#3498db', '#e74c3c', '#9b59b6']; const stateLabel = stateLabels[imageState] || ''; const stateColor = stateColors[imageState] || '';
if (wasSplit) { conversionStats.splits++; conversionStats.splitParts += splitCount; // Build parts detail string let partsDetail = ''; if (partsInfo && partsInfo.length > 0) { const baseName = name.replace(/\.[^.]+$/, ''); partsDetail = partsInfo.map(p => `${baseName}${p.suffix}.jpg (${p.width}×${p.height}, ${formatBytes(p.size)})` ).join(', '); } const savedInfo = saved > 0 ? `, <span style="color:#27ae60">-${savedPct}%</span>` : ''; const stateIndicator = imageState > 0 ? ` <span style="color:${stateColor};font-weight:600;">[${stateLabel}]</span>` : ''; log(`<strong>${escapeHtml(name)}</strong>${stateIndicator} <span class="log-detail">(${dims} ${origFormat.toUpperCase()}, ${formatBytes(origSize)}) → ${splitCount} parts${savedInfo}</span>`, '', 'SPLIT'); if (partsDetail) { log(`<span class="log-detail" style="margin-left: 20px;">↳ ${partsDetail}</span>`, '', ''); } } else { conversionStats.images++; const stateIndicator = imageState > 0 ? ` <span style="color:${stateColor};font-weight:600;">[${stateLabel}]</span>` : ''; const detail = saved > 0 ? `<span class="log-detail">(${dims} → ${newDims}, ${formatBytes(origSize)} → ${formatBytes(newSize)}, <span style="color:#27ae60">-${savedPct}%</span>)</span>` : `<span class="log-detail">(${dims} → ${newDims}, ${formatBytes(newSize)})</span>`; log(`<strong>${escapeHtml(name)}</strong>${stateIndicator} ${detail}`, '', 'CONVERT'); }}
// Log fix appliedfunction logFix(type, detail) { conversionStats.fixes++; log(`${type}: <span class="log-detail">${detail}</span>`, 'success', 'FIX');}
// Log skipped itemfunction logSkip(name, reason) { conversionStats.skipped++; log(`${escapeHtml(name)} <span class="log-detail">(${reason})</span>`, '', 'SKIP');}
// Log errorfunction logError(message) { conversionStats.errors++; log(message, 'error', 'ERROR');}
// Log summary tablefunction logSummary(originalSize, newSize, timeElapsed) { const saved = originalSize - newSize; const savedPct = ((saved / originalSize) * 100).toFixed(1); const totalImages = conversionStats.images + conversionStats.splits; const totalOutput = conversionStats.images + conversionStats.splitParts;
const summaryHtml = ` <div class="log-summary"> <div class="log-summary-title">📊 Conversion Summary</div> <table class="log-summary-table"> <tr><td>Images found</td><td class="highlight">${totalImages}</td></tr> <tr><td>Images processed</td><td>${totalOutput}${conversionStats.splitParts > conversionStats.splits ? ` (+${conversionStats.splitParts - conversionStats.splits} from splits)` : ''}</td></tr> <tr><td>EPUB repairs</td><td>${conversionStats.fixes > 0 ? conversionStats.fixes + ' fixes applied' : 'None needed'}</td></tr> ${conversionStats.errors > 0 ? `<tr><td>Errors</td><td style="color:#e74c3c">${conversionStats.errors}</td></tr>` : ''} <tr><td>Original size</td><td>${formatBytes(originalSize)}</td></tr> <tr><td>Optimized size</td><td>${formatBytes(newSize)}</td></tr> <tr><td>Saved</td><td class="${saved > 0 ? 'saved' : 'increased'}">${saved > 0 ? formatBytes(saved) + ' (' + savedPct + '%)' : '+' + formatBytes(-saved)}</td></tr> <tr><td>Time</td><td>${timeElapsed.toFixed(1)}s</td></tr> </table> </div> `; logContainer.insertAdjacentHTML('beforeend', summaryHtml); logContainer.scrollTop = logContainer.scrollHeight;}
// Clear logfunction clearLog() { logContainer.innerHTML = ''; logStartTime = Date.now(); conversionStats = { images: 0, splits: 0, splitParts: 0, fixes: 0, skipped: 0, errors: 0, originalSize: 0, newSize: 0 };}
// Start batch logging modefunction startBatchLog(fileCount) { isBatchMode = true; batchStartTime = Date.now(); batchLogEntries = []; batchStats = { filesProcessed: 0, filesSucceeded: 0, filesFailed: 0, totalImages: 0, totalSplits: 0, totalFixes: 0, totalErrors: 0, totalOriginalSize: 0, totalNewSize: 0 }; clearLog(); logContainer.innerHTML = ''; // Clear display log(`Starting batch conversion: ${fileCount} file(s)`, '', 'INFO');}
// Save current file's log to batch entriesfunction saveToFileBatchLog(fileName, succeeded, originalSize = 0, newSize = 0) { if (!isBatchMode) return;
const entries = Array.from(logContainer.querySelectorAll('.log-entry')); batchLogEntries.push({ fileName: fileName, succeeded: succeeded, entries: entries, stats: { ...conversionStats } });
// Update batch stats batchStats.filesProcessed++; if (succeeded) { batchStats.filesSucceeded++; } else { batchStats.filesFailed++; } batchStats.totalImages += conversionStats.images; batchStats.totalSplits += conversionStats.splits; batchStats.totalFixes += conversionStats.fixes; batchStats.totalErrors += conversionStats.errors; // Defaults of 0 keep failure-path callers safe — files that never // produced a converted blob contribute nothing to the totals. batchStats.totalOriginalSize += originalSize; batchStats.totalNewSize += newSize;
// Clear for next file logContainer.innerHTML = ''; conversionStats = { images: 0, splits: 0, splitParts: 0, fixes: 0, skipped: 0, errors: 0, originalSize: 0, newSize: 0 };}
// Finalize batch log and exportfunction finalizeBatchLog() { if (!isBatchMode) return;
const batchTime = (Date.now() - batchStartTime) / 1000;
// Build consolidated log display logContainer.innerHTML = ''; log(`Starting batch conversion: ${batchStats.filesProcessed} file(s)`, '', 'INFO');
// Add all file entries batchLogEntries.forEach((fileLog, index) => { const fileHeader = document.createElement('div'); fileHeader.className = 'log-entry'; fileHeader.style.marginTop = index > 0 ? '15px' : '5px'; fileHeader.style.borderTop = index > 0 ? '1px solid var(--border-color)' : 'none'; fileHeader.style.paddingTop = index > 0 ? '10px' : '0'; fileHeader.innerHTML = `<span class="log-timestamp"></span><span class="log-message"><strong>${escapeHtml(fileLog.fileName)}</strong> — ${fileLog.succeeded ? '<span style="color:#27ae60">✓ Success</span>' : '<span style="color:#e74c3c">✗ Failed</span>'}</span>`; logContainer.appendChild(fileHeader);
fileLog.entries.forEach(entry => { const clone = entry.cloneNode(true); logContainer.appendChild(clone); }); });
// Aggregate size totals: only emit rows when at least one file was successfully // converted (totalOriginalSize stays 0 for batches where conversion was off or // every file fell back to original upload). const totalSaved = batchStats.totalOriginalSize - batchStats.totalNewSize; const totalSavedPct = batchStats.totalOriginalSize > 0 ? ((totalSaved / batchStats.totalOriginalSize) * 100).toFixed(1) : '0.0'; const sizeRowsHtml = batchStats.totalOriginalSize > 0 ? ` <tr><td>Total original</td><td>${formatBytes(batchStats.totalOriginalSize)}</td></tr> <tr><td>Total optimised</td><td>${formatBytes(batchStats.totalNewSize)}</td></tr> <tr><td>Total saved</td><td class="${totalSaved > 0 ? 'saved' : 'increased'}">${ totalSaved > 0 ? `${formatBytes(totalSaved)} (${totalSavedPct}%)` : `+${formatBytes(-totalSaved)}` }</td></tr>` : '';
// Add batch summary const batchSummaryHtml = ` <div class="log-summary"> <div class="log-summary-title">📊 Batch Conversion Summary</div> <table class="log-summary-table"> <tr><td>Files processed</td><td class="highlight">${batchStats.filesProcessed}</td></tr> <tr><td>Successful</td><td style="color:#27ae60">${batchStats.filesSucceeded}</td></tr> <tr><td>Failed</td><td style="${batchStats.filesFailed > 0 ? '#e74c3c' : '#7f8c8d'}">${batchStats.filesFailed}</td></tr> <tr><td>Total images processed</td><td>${batchStats.totalImages}</td></tr> <tr><td>Total splits</td><td>${batchStats.totalSplits}</td></tr> <tr><td>Total fixes applied</td><td>${batchStats.totalFixes}</td></tr> ${batchStats.totalErrors > 0 ? `<tr><td>Total errors</td><td style="color:#e74c3c">${batchStats.totalErrors}</td></tr>` : ''}${sizeRowsHtml} <tr><td>Total time</td><td>${batchTime.toFixed(1)}s</td></tr> </table> </div> `; logContainer.insertAdjacentHTML('beforeend', batchSummaryHtml); logContainer.scrollTop = logContainer.scrollHeight;
// Auto-export if checkbox is checked if (exportLogCheckbox && exportLogCheckbox.checked) { setTimeout(() => { exportLogToFile(null, true); // isBatch = true }, 200); }
// Reset batch mode isBatchMode = false; batchLogEntries = [];}
// Show/hide log sectionfunction showLog() { logSection.classList.add('visible');}
function hideLog() { logSection.classList.remove('visible');}
// Generate standardized log filename with datefunction generateLogFilename(isBatch = false) { const now = new Date(); const date = now.toISOString().split('T')[0]; // YYYY-MM-DD const time = now.toTimeString().split(' ')[0].replace(/:/g, '-'); // HH-MM-SS const prefix = isBatch ? 'batch' : 'epub'; return `${prefix}-conversion-log-${date}_${time}.txt`;}
// Export log as text file (can be called automatically)function exportLogToFile(filename = null, isBatch = false) { // Use standardized filename if none provided if (!filename) { filename = generateLogFilename(isBatch); } // Extract text from log entries const entries = logContainer.querySelectorAll('.log-entry'); let logText = `CrossPoint Reader ${crosspointVersion} - EPUB Conversion Log\n`; logText += `Generated: ${new Date().toLocaleString()}\n`; logText += `${'='.repeat(60)}\n\n`;
entries.forEach(entry => { const timestamp = entry.querySelector('.log-timestamp')?.textContent || ''; const tag = entry.querySelector('.log-tag')?.textContent || ''; const message = entry.querySelector('.log-message')?.textContent || entry.textContent;
if (tag) { logText += `${timestamp} [${tag}] ${message}\n`; } else { logText += `${timestamp} ${message}\n`; } });
// Extract summary table if present const summary = logContainer.querySelector('.log-summary'); if (summary) { logText += `\n${'='.repeat(60)}\n`; const title = summary.querySelector('.log-summary-title')?.textContent || 'Summary'; logText += `${title}\n`; logText += `${'-'.repeat(40)}\n`;
const rows = summary.querySelectorAll('tr'); rows.forEach(row => { const cells = row.querySelectorAll('td'); if (cells.length >= 2) { logText += `${cells[0].textContent.padEnd(25)} ${cells[1].textContent}\n`; } }); }
// Create download link const blob = new Blob([logText], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename || `epub-conversion-log-${Date.now()}.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);}
// ═══════════════════════════════════════════════════════════════════// EPUB Utilities — ported from EPUB Optimizer Pro// ═══════════════════════════════════════════════════════════════════
/** Defensive CSS injected into every XHTML <head> — prevents e-ink overflow. */const DEFENSIVE_STYLE = '<style type="text/css">img,svg{max-width:100%;height:auto}body{overflow-wrap:break-word}table{max-width:100%;table-layout:fixed}pre,code{white-space:pre-wrap;word-wrap:break-word}*{box-sizing:border-box}</style>';
/** Escape a string for safe insertion into XML attribute values / text content. */function xmlEscape(str) { return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');}
function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
/** * Decode a URI-encoded href (e.g., "my%20image.jpg" → "my image.jpg"). * Handles double-encoding gracefully. */function decodeHref(href) { try { return decodeURIComponent(href); } catch (e) { return href; }}
/** * Safely read a text file from the zip, handling BOM and encoding. * Strips UTF-8 BOM. Detects encoding from XML declaration or meta tag. */async function safeReadText(fileObj) { const raw = await fileObj.async('uint8array');
// Detect and strip UTF-8 BOM (EF BB BF) let offset = 0; if (raw.length >= 3 && raw[0] === 0xEF && raw[1] === 0xBB && raw[2] === 0xBF) { offset = 3; }
// Try UTF-8 first (vast majority of EPUBs) const utf8 = new TextDecoder('utf-8', { fatal: true }); try { return utf8.decode(raw.subarray(offset)); } catch (e) { /* not valid UTF-8 */ }
// Peek at XML declaration or meta charset for encoding hint const ascii = new TextDecoder('ascii', { fatal: false }).decode(raw.subarray(offset, offset + 512)); const encodingMatch = ascii.match(/encoding=["']([^"']+)["']/i) || ascii.match(/charset=["']?([^"'\s;]+)/i); const encoding = encodingMatch ? encodingMatch[1].toLowerCase() : 'windows-1252';
try { return new TextDecoder(encoding, { fatal: false }).decode(raw.subarray(offset)); } catch (e) { // Last resort: lossy latin1 return new TextDecoder('iso-8859-1', { fatal: false }).decode(raw.subarray(offset)); }}
/** * Find the canonical OPF path by parsing META-INF/container.xml. * Falls back to scanning for any .opf file. */async function findOPFPath(zip) { try { const containerPath = Object.keys(zip.files).find(p => p.toLowerCase() === 'meta-inf/container.xml'); if (containerPath) { const containerXml = await zip.files[containerPath].async('string'); const match = containerXml.match(/<rootfile[^>]+full-path=["']([^"']+)["']/i); if (match && zip.files[match[1]]) return match[1]; } } catch (e) { /* fall through */ } let fallback = null; zip.forEach(p => { if (!fallback && p.toLowerCase().endsWith('.opf')) fallback = p; }); return fallback;}
function sanitizeMetadataFilenamePart(value) { const text = String(value || '').replace(/\s+/g, ' ').trim(); return (text.normalize ? text.normalize('NFC') : text) .replace(/[<>:"/\\|?*\x00-\x1F]/g, ' ') .replace(/\s+/g, ' ') .replace(/^[. ]+/g, '') .replace(/[. ]+$/g, '') .trim();}
function buildMetadataFilename(title, author) { title = sanitizeMetadataFilenamePart(title); author = sanitizeMetadataFilenamePart(author); if (!title) return ''; let base = author ? `${title} - ${author}` : title; if (base.length > 180) base = base.substring(0, 180).replace(/\s+\S*$/g, '').trim() || base.substring(0, 180).trim(); return `${base}.epub`;}
async function getMetadataFilenameForEpub(file) { if (typeof JSZip === 'undefined') return ''; const zip = await JSZip.loadAsync(file); const opfPath = await findOPFPath(zip); if (!opfPath || !zip.files[opfPath]) return '';
const doc = new DOMParser().parseFromString(await safeReadText(zip.files[opfPath]), 'application/xml'); if (doc.querySelector('parsererror')) return '';
const title = doc.getElementsByTagNameNS('*', 'title')[0]?.textContent || ''; const creators = Array.from(doc.getElementsByTagNameNS('*', 'creator')); const getCreatorRole = el => ( el.getAttribute('role') || el.getAttribute('opf:role') || el.getAttributeNS('http://www.idpf.org/2007/opf', 'role') || '' ).toLowerCase(); const authorEl = creators.find(el => getCreatorRole(el) === 'aut') || creators.find(el => !getCreatorRole(el)); const authorText = authorEl?.textContent?.trim(); const author = authorText || authorEl?.getAttribute('file-as') || authorEl?.getAttribute('opf:file-as') || authorEl?.getAttributeNS('http://www.idpf.org/2007/opf', 'file-as') || ''; return buildMetadataFilename(title, author);}
async function maybeRenameEbookFile(file) { const renameToggle = document.getElementById('renameFromMetadataToggle'); if (!renameToggle || !renameToggle.checked || !file.name.toLowerCase().endsWith('.epub')) { return file; }
try { const metadataName = await getMetadataFilenameForEpub(file); if (!metadataName || metadataName === file.name) return file; return new File([file], metadataName, { type: file.type || 'application/epub+zip', lastModified: file.lastModified }); } catch (e) { console.warn('Could not rename EPUB from metadata:', e); return file; }}
function reserveAvailableUploadFilename(fileName, usedFileNames) { const normalize = name => name.toLowerCase(); if (!usedFileNames.has(normalize(fileName))) { usedFileNames.add(normalize(fileName)); return fileName; }
const dotIndex = fileName.lastIndexOf('.'); const extensionIndex = dotIndex > 0 ? dotIndex : fileName.length; const baseName = fileName.substring(0, extensionIndex); const extension = fileName.substring(extensionIndex); let nextSuffix = 2;
let candidateName; do { candidateName = `${baseName} (${nextSuffix})${extension}`; nextSuffix++; } while (usedFileNames.has(normalize(candidateName)));
usedFileNames.add(normalize(candidateName)); return candidateName;}
async function fetchExistingUploadNames() { const response = await fetch('/api/files?path=' + encodeURIComponent(currentPath) + '&_=' + Date.now()); if (!response.ok) { throw new Error(response.status + ' ' + response.statusText); } const entries = await response.json(); return new Set(entries.map(entry => entry.name.toLowerCase()));}
/** * Resolve a relative href against a base file path. * Handles multiple ../, ./, absolute /, and bare relative paths. */function resolvePath(basePath, href) { if (href.startsWith('/')) return href.substring(1); href = href.replace(/^\.\//, ''); const baseDir = basePath.includes('/') ? basePath.substring(0, basePath.lastIndexOf('/')) : ''; const baseParts = baseDir ? baseDir.split('/') : []; const hrefParts = href.split('/'); while (hrefParts.length > 0 && hrefParts[0] === '..') { hrefParts.shift(); if (baseParts.length > 0) baseParts.pop(); } const resolved = [...baseParts, ...hrefParts].join('/'); return resolved.replace(/\/+/g, '/');}
/** Remove XML comments from XHTML source text. Returns {text, count, bytes}. */function stripComments(xml) { const SKIPPED = [ { open: '<![CDATA[', close: ']]>' }, { open: '<?', close: '?>' }, ];
const encoder = new TextEncoder(); const out = []; let i = 0; let count = 0; let bytes = 0;
const doctypeEnd = (from) => { const gt = xml.indexOf('>', from); const subset = xml.indexOf('[', from); if (subset < 0 || (gt >= 0 && subset > gt)) return gt < 0 ? xml.length : gt + 1; const close = xml.indexOf(']>', subset); return close < 0 ? xml.length : close + 2; };
while (i < xml.length) { const next = xml.indexOf('<', i); if (next < 0) { out.push(xml.slice(i)); break; } if (next > i) out.push(xml.slice(i, next));
if (xml.startsWith('<!--', next)) { const close = xml.indexOf('-->', next + 4); const end = close < 0 ? xml.length : close + 3; count++; bytes += encoder.encode(xml.slice(next, end)).length; i = end; continue; }
if (xml.startsWith('<!DOCTYPE', next)) { const end = doctypeEnd(next + 9); out.push(xml.slice(next, end)); i = end; continue; }
const skipped = SKIPPED.find(s => xml.startsWith(s.open, next)); if (skipped) { const close = xml.indexOf(skipped.close, next + skipped.open.length); const end = close < 0 ? xml.length : close + skipped.close.length; out.push(xml.slice(next, end)); i = end; continue; }
out.push('<'); i = next + 1; }
return { text: out.join(''), count, bytes };}
/** * Serialize an XML doc back to string, preserving the original <?xml?> declaration * and cleaning up XMLSerializer namespace prefix noise (xmlns:ns0 etc). */function safeSerialize(doc, originalContent) { let result = new XMLSerializer().serializeToString(doc);
// Restore <?xml?> declaration if original had one if (originalContent && /^\s*<\?xml\b/.test(originalContent) && !/^\s*<\?xml\b/.test(result)) { const declMatch = originalContent.match(/^\s*(<\?xml[^?]*\?>)/); if (declMatch) result = declMatch[1] + '\n' + result; }
// Clean up XMLSerializer namespace prefix noise (xmlns:ns0="..." ns0:attr="...") result = result.replace(/ xmlns:ns\d+="[^"]*"/g, ''); result = result.replace(/ ns\d+:/g, ' ');
return result;}
/** * Extract main identifier from OPF for NCX sync. DOMParser with regex fallback. */function extractIdentifier(opfContent) { let mainIdentifier = null; try { const doc = new DOMParser().parseFromString(opfContent, 'application/xml'); if (!doc.querySelector('parsererror')) { const pkg = doc.getElementsByTagNameNS('*', 'package')[0]; const uid = pkg ? pkg.getAttribute('unique-identifier') : null; if (uid) { const el = [...doc.getElementsByTagNameNS('*', 'identifier')].find(e => e.getAttribute('id') === uid); if (el) mainIdentifier = (el.textContent || '').trim(); } if (!mainIdentifier) { const el = doc.getElementsByTagNameNS('*', 'identifier')[0]; if (el) mainIdentifier = (el.textContent || '').trim(); } } } catch (e) { /* fall through to regex */ } if (!mainIdentifier) { const uniqueIdMatch = opfContent.match(/<(?:\w+:)?package[^>]*unique-identifier=["']([^"']+)["']/i); if (uniqueIdMatch) { const idRegex = new RegExp(`<dc:identifier[^>]*id=["']${uniqueIdMatch[1]}["'][^>]*>([^<]+)</dc:identifier>`, 'i'); const idMatch = opfContent.match(idRegex); if (idMatch) mainIdentifier = idMatch[1].trim(); } if (!mainIdentifier) { const firstIdMatch = opfContent.match(/<dc:identifier[^>]*>([^<]+)</i); if (firstIdMatch) mainIdentifier = firstIdMatch[1].trim(); } } return mainIdentifier;}
/** * Sync NCX dtb:uid with the given identifier. DOMParser with regex fallback. */function syncNCXIdentifier(ncxText, mainIdentifier) { if (!mainIdentifier) return ncxText; let t = ncxText; try { const doc = new DOMParser().parseFromString(t, 'application/xml'); if (!doc.querySelector('parsererror')) { const meta = [...doc.getElementsByTagNameNS('*', 'meta')].find(m => m.getAttribute('name') === 'dtb:uid'); if (meta) { meta.setAttribute('content', mainIdentifier); t = safeSerialize(doc, ncxText); } } } catch (e) { t = t.replace(/<meta\s+name=["']dtb:uid["']\s+content=["'][^"']*["']\s*\/?>/gi, `<meta name="dtb:uid" content="${xmlEscape(mainIdentifier)}"/>`); } return t;}
// ===== Embedded font removal =====// CrossPoint renders with built-in / SD-card fonts and never loads fonts// embedded in an EPUB, so they are dead weight (often 100KB-2MB per book).const FONT_EXT_REGEX = /\.(ttf|otf|woff2?|eot)$/;const FONT_MEDIA_TYPE_REGEX = /^(font\/|application\/font-|application\/x-font-|application\/vnd\.ms-(opentype|fontobject)$)/i;
// Returns a Set of zip-root-relative paths of embedded font files, detected by// file extension and by OPF manifest media-type (catches odd extensions).function collectFontPaths(zip, opfContent, opfPath) { const fontPaths = new Set(); zip.forEach(p => { if (FONT_EXT_REGEX.test(p.toLowerCase())) fontPaths.add(p); });
if (opfContent && opfPath) { try { const doc = new DOMParser().parseFromString(opfContent, 'application/xml'); if (!doc.querySelector('parsererror')) { for (const item of [...doc.getElementsByTagNameNS('*', 'item')]) { const type = item.getAttribute('media-type') || ''; const href = item.getAttribute('href') || ''; if (href && FONT_MEDIA_TYPE_REGEX.test(type)) { const resolved = resolvePath(opfPath, decodeHref(href)); if (zip.files[resolved]) fontPaths.add(resolved); } } } } catch (e) { // Extension-based detection already covers the common cases } } return fontPaths;}
// Strips all @font-face rules from CSS. Rules can also embed fonts directly as// multi-hundred-KB data: URIs, so this is a size win even without font files.function stripFontFaceRules(css) { let count = 0; const stripped = css.replace(/@font-face(?:\s|\/\*[\s\S]*?\*\/)*\{[^{}]*\}/gi, () => { count++; return ''; }); return { css: stripped, count };}
// Removes font-obfuscation <EncryptedData> entries from META-INF/encryption.xml.// Returns { dropFile: true } when nothing meaningful remains, { modified, xml }// when some entries were removed, or { modified: false } to keep the file as-is.function stripFontEncryptionEntries(xmlText, fontPaths) { try { const doc = new DOMParser().parseFromString(xmlText, 'application/xml'); if (doc.querySelector('parsererror')) return { modified: false }; const entries = [...doc.getElementsByTagNameNS('*', 'EncryptedData')]; let removed = 0; for (const entry of entries) { const ref = entry.getElementsByTagNameNS('*', 'CipherReference')[0]; const uri = ref ? decodeHref(ref.getAttribute('URI') || '') : ''; if (fontPaths.has(uri.replace(/^\//, ''))) { entry.parentNode.removeChild(entry); removed++; } } if (!removed) return { modified: false }; const remaining = doc.getElementsByTagNameNS('*', 'EncryptedData').length + doc.getElementsByTagNameNS('*', 'EncryptedKey').length; if (remaining === 0) return { dropFile: true }; return { modified: true, xml: safeSerialize(doc, xmlText) }; } catch (e) { return { modified: false }; }}
/** * Fix OPF content: fix media-types, strip svg properties, * update split image manifest entries, remove stripped font * entries, ensure cover meta. DOMParser with regex fallback. */function fixOPF(opfText, opfOriginal, opfDir, splitImages = {}, fontPaths = null) { let t = opfText;
try { const parser = new DOMParser(); const doc = parser.parseFromString(t, 'application/xml'); if (doc.querySelector('parsererror')) throw new Error('OPF parse failed');
const items = [...doc.getElementsByTagNameNS('*', 'item')]; const manifestEl = doc.getElementsByTagNameNS('*', 'manifest')[0];
// Fix media-types for converted images for (const item of items) { const href = item.getAttribute('href') || ''; const type = item.getAttribute('media-type') || ''; if (href.endsWith('.jpg') && type.match(/^image\/(png|gif|webp|bmp)$/)) { item.setAttribute('media-type', 'image/jpeg'); } }
// Remove 'svg' from properties for (const item of items) { const props = item.getAttribute('properties') || ''; if (props.includes('svg')) { const newProps = props.split(/\s+/).filter(p => p !== 'svg').join(' ').trim(); if (newProps) item.setAttribute('properties', newProps); else item.removeAttribute('properties'); } }
// Remove manifest entries for stripped embedded fonts if (fontPaths && fontPaths.size) { for (const item of items) { const href = decodeHref(item.getAttribute('href') || ''); const full = resolvePath((opfDir ? opfDir + '/' : '') + 'x', href); if (fontPaths.has(full)) item.parentNode.removeChild(item); } }
// Update split image hrefs and add manifest entries for parts for (const [splitKey, splitInfo] of Object.entries(splitImages)) { const parts = splitInfo.parts || splitInfo; let origHref = opfDir && splitKey.startsWith(opfDir + '/') ? splitKey.substring(opfDir.length + 1) : splitKey; const origHrefJpg = origHref.replace(/\.(png|gif|webp|bmp|jpeg)$/i, '.jpg'); const part1Href = origHrefJpg.replace(/\.jpg$/i, '_part1.jpg');
for (const item of items) { const h = item.getAttribute('href') || ''; if (h === origHref || h === origHrefJpg || decodeHref(h) === origHref || decodeHref(h) === origHrefJpg) { item.setAttribute('href', part1Href); break; } }
if (manifestEl) { const ns = manifestEl.namespaceURI || 'http://www.idpf.org/2007/opf'; for (let j = 1; j < parts.length; j++) { const p = parts[j]; const href = opfDir && p.path.startsWith(opfDir + '/') ? p.path.substring(opfDir.length + 1) : p.path; const newItem = doc.createElementNS(ns, 'item'); newItem.setAttribute('id', `img-${p.id}`); newItem.setAttribute('href', href); newItem.setAttribute('media-type', 'image/jpeg'); manifestEl.appendChild(newItem); } } }
t = safeSerialize(doc, opfOriginal); } catch (e) { // Regex fallback t = t.replace(/(<(?:\w+:)?item\b[^>]*href="[^"]+\.jpg"[^>]*)media-type="image\/(png|gif|webp|bmp)"/g, '$1media-type="image/jpeg"'); t = t.replace(/(<(?:\w+:)?item\b[^>]*)media-type="image\/(png|gif|webp|bmp)"([^>]*href="[^"]+\.jpg")/g, '$1media-type="image/jpeg"$3'); t = t.replace(/\s+svg(?=["'\s>])/g, ''); if (fontPaths && fontPaths.size) { for (const p of fontPaths) { const href = opfDir && p.startsWith(opfDir + '/') ? p.substring(opfDir.length + 1) : p; const itemRegex = new RegExp(`<(?:\\w+:)?item\\b[^>]*href=["']${escapeRegex(href)}["'][^>]*\\/?>\\s*`, 'gi'); t = t.replace(itemRegex, ''); } } for (const [splitKey, splitInfo] of Object.entries(splitImages)) { const parts = splitInfo.parts || splitInfo; let origHref = opfDir && splitKey.startsWith(opfDir + '/') ? splitKey.substring(opfDir.length + 1) : splitKey; const origHrefJpg = origHref.replace(/\.(png|gif|webp|bmp|jpeg)$/i, '.jpg'); const part1Href = origHrefJpg.replace(/\.jpg$/i, '_part1.jpg'); const origImgRegex = new RegExp(`(href=["'])(${escapeRegex(origHref)}|${escapeRegex(origHrefJpg)})(["'])`, 'gi'); t = t.replace(origImgRegex, `$1${part1Href}$3`); let adds = ''; for (let j = 1; j < parts.length; j++) { const p = parts[j]; const href = opfDir && p.path.startsWith(opfDir + '/') ? p.path.substring(opfDir.length + 1) : p.path; adds += `<item id="img-${xmlEscape(p.id)}" href="${xmlEscape(href)}" media-type="image/jpeg"/>\n`; } if (adds && t.includes('</manifest>')) t = t.replace('</manifest>', adds + '</manifest>'); } }
// Ensure cover meta const cm = ensureCoverMeta(t); if (cm.fixed) t = cm.o;
return t;}
// Fix SVG cover - converts SVG-wrapped covers to plain HTML img tagsfunction fixSvgCover(content) { const hasSvg = content.includes('<svg') || content.includes('<svg:'); if (!hasSvg || !content.includes('xlink:href')) return { c: content, fixed: false, count: 0 }; if (!content.includes('calibre:cover') && !content.includes('name="cover"') && !content.includes('<title>Cover</title>')) return { c: content, fixed: false, count: 0 };
try { const parser = new DOMParser(); const doc = parser.parseFromString(content, 'application/xhtml+xml');
if (doc.querySelector('parsererror')) { // Fallback to regex const m = content.match(/xlink:href=["']([^"']+)["']/); if (!m) return { c: content, fixed: false, count: 0 }; return { c: `<?xml version="1.0" encoding="utf-8"?><!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en" xml:lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="default-style"/><title>Cover</title></head><body><section epub:type="cover"><img style="max-width:100%;height:auto" alt="Cover" src="${m[1]}"/></section></body></html>`, fixed: true, count: 1 }; }
// Find SVG elements - check both standard and namespaced variants let imgHref = null; const svgNS = 'http://www.w3.org/2000/svg'; const xlinkNS = 'http://www.w3.org/1999/xlink';
// Try to find all SVG elements const svgs = [ ...doc.getElementsByTagName('svg'), ...doc.getElementsByTagNameNS(svgNS, 'svg'), ...doc.getElementsByTagName('svg:svg') ];
for (const svg of svgs) { // Find image element inside - try all variants const imageEl = svg.getElementsByTagName('image')[0] || svg.getElementsByTagNameNS(svgNS, 'image')[0] || svg.getElementsByTagName('svg:image')[0];
if (imageEl) { imgHref = imageEl.getAttributeNS(xlinkNS, 'href') || imageEl.getAttribute('xlink:href') || imageEl.getAttribute('href'); if (imgHref) break; } }
if (!imgHref) { // Fallback to regex const m = content.match(/xlink:href=["']([^"']+)["']/); if (!m) return { c: content, fixed: false, count: 0 }; imgHref = m[1]; }
return { c: `<?xml version="1.0" encoding="utf-8"?><!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en" xml:lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="default-style"/><title>Cover</title></head><body><section epub:type="cover"><img style="max-width:100%;height:auto" alt="Cover" src="${imgHref}"/></section></body></html>`, fixed: true, count: 1 }; } catch (e) { // Fallback to regex const m = content.match(/xlink:href=["']([^"']+)["']/); if (!m) return { c: content, fixed: false, count: 0 }; return { c: `<?xml version="1.0" encoding="utf-8"?><!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en" xml:lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="default-style"/><title>Cover</title></head><body><section epub:type="cover"><img style="max-width:100%;height:auto" alt="Cover" src="${m[1]}"/></section></body></html>`, fixed: true, count: 1 }; }}
// Fix SVG-wrapped images - unwrap SVG and replace with plain imgfunction fixSvgWrappedImages(content) { const hasSvg = content.includes('<svg') || content.includes('<svg:'); if (!hasSvg || !content.includes('xlink:href')) return { c: content, fixed: false, count: 0 };
try { const parser = new DOMParser(); const doc = parser.parseFromString(content, 'application/xhtml+xml');
if (doc.querySelector('parsererror')) { // Fallback to regex let fixedCount = 0; const svgImageRegex = /<(?:svg:)?svg\b[^>]*>[\s\S]*?<(?:svg:)?image\b[^>]*xlink:href=["']([^"']+)["'][^>]*\/?>\s*<\/(?:svg:)?svg>/gi; const newContent = content.replace(svgImageRegex, (match, href) => { fixedCount++; return `<img style="max-width:100%;height:auto" src="${href}" alt="" />`; }); return { c: newContent, fixed: fixedCount > 0, count: fixedCount }; }
const svgNS = 'http://www.w3.org/2000/svg'; const xlinkNS = 'http://www.w3.org/1999/xlink';
const svgElements = [...doc.querySelectorAll('svg'), ...doc.getElementsByTagNameNS(svgNS, 'svg')]; const uniqueSvgs = [...new Set(svgElements)]; let fixedCount = 0;
for (const svg of uniqueSvgs) { const imageEl = svg.querySelector('image[*|href]') || svg.getElementsByTagNameNS(svgNS, 'image')[0] || svg.getElementsByTagNameNS('*', 'image')[0]; if (!imageEl) continue; const href = imageEl.getAttributeNS(xlinkNS, 'href') || imageEl.getAttribute('xlink:href') || imageEl.getAttribute('href'); if (!href) continue; const width = imageEl.getAttribute('width') || svg.getAttribute('width'); const height = imageEl.getAttribute('height') || svg.getAttribute('height'); const img = doc.createElementNS('http://www.w3.org/1999/xhtml', 'img'); img.setAttribute('src', href); img.setAttribute('alt', ''); img.setAttribute('style', 'max-width:100%;height:auto'); if (width) img.setAttribute('width', width); if (height) img.setAttribute('height', height); svg.parentNode.replaceChild(img, svg); fixedCount++; }
if (fixedCount === 0) return { c: content, fixed: false, count: 0 }; return { c: safeSerialize(doc, content), fixed: true, count: fixedCount };
} catch (e) { // Fallback to regex let fixedCount = 0; const svgImageRegex = /<(?:svg:)?svg\b[^>]*>[\s\S]*?<(?:svg:)?image\b[^>]*xlink:href=["']([^"']+)["'][^>]*\/?>\s*<\/(?:svg:)?svg>/gi; const newContent = content.replace(svgImageRegex, (match, href) => { fixedCount++; return `<img style="max-width:100%;height:auto" src="${href}" alt="" />`; }); return { c: newContent, fixed: fixedCount > 0, count: fixedCount }; }}
// Ensure cover meta tag exists in OPF — DOMParser with regex fallbackfunction ensureCoverMeta(opfString) { try { const parser = new DOMParser(); const doc = parser.parseFromString(opfString, 'application/xml'); if (doc.querySelector('parsererror')) throw new Error('Parse failed');
// Find cover image id: properties="cover-image", or id/href containing "cover" let coverId = null; const items = [...doc.getElementsByTagNameNS('*', 'item')]; for (const item of items) { const props = item.getAttribute('properties') || ''; const id = item.getAttribute('id') || ''; const type = item.getAttribute('media-type') || ''; if (!type.startsWith('image/')) continue; if (props.includes('cover-image')) { coverId = id; break; } } if (!coverId) { for (const item of items) { const id = item.getAttribute('id') || ''; const href = item.getAttribute('href') || ''; const type = item.getAttribute('media-type') || ''; if (!type.startsWith('image/')) continue; if (id.toLowerCase().includes('cover') || href.toLowerCase().includes('cover')) { coverId = id; break; } } } if (!coverId) return { o: opfString, fixed: false };
// Find or create <meta name="cover" content="..."/> const metas = [...doc.getElementsByTagNameNS('*', 'meta')]; const coverMeta = metas.find(m => m.getAttribute('name') === 'cover'); if (coverMeta) { if (coverMeta.getAttribute('content') === coverId) return { o: opfString, fixed: false }; coverMeta.setAttribute('content', coverId); } else { const metadata = doc.getElementsByTagNameNS('*', 'metadata')[0]; if (!metadata) return { o: opfString, fixed: false }; const ns = metadata.namespaceURI || 'http://www.idpf.org/2007/opf'; const newMeta = doc.createElementNS(ns, 'meta'); newMeta.setAttribute('name', 'cover'); newMeta.setAttribute('content', coverId); metadata.appendChild(newMeta); } return { o: safeSerialize(doc, opfString), fixed: true }; } catch (e) { // Regex fallback return ensureCoverMetaRegex(opfString); }}
function ensureCoverMetaRegex(o) { let coverId = null, m; if (!coverId && (m = o.match(/<\w+:?item[^>]+id="([^"]+)"[^>]+properties="[^"]*cover-image[^"]*"/i))) coverId = m[1]; if (!coverId && (m = o.match(/<\w+:?item[^>]+properties="[^"]*cover-image[^"]*"[^>]+id="([^"]+)"/i))) coverId = m[1]; if (!coverId && (m = o.match(/<\w+:?item[^>]*id="([^"]+)"[^>]*href="[^"]*cover[^"]*"[^>]*media-type="image\//i))) coverId = m[1]; if (!coverId && (m = o.match(/<\w+:?item[^>]*href="[^"]*cover[^"]*"[^>]*id="([^"]+)"[^>]*media-type="image\//i))) coverId = m[1]; if (!coverId && (m = o.match(/<\w+:?item[^>]*id="([^"]*cover[^"]*)"[^>]*media-type="image\//i))) coverId = m[1]; if (!coverId && (m = o.match(/<\w+:?item[^>]*media-type="image\/[^"]*"[^>]*id="([^"]*cover[^"]*)"/i))) coverId = m[1]; if (!coverId) return { o, fixed: false }; const metaMatch = o.match(/<\w+:?meta\s+name=["']cover["']\s+content=["']([^"']+)["']/i) || o.match(/<\w+:?meta\s+content=["']([^"']+)["']\s+name=["']cover["']/i); if (metaMatch) { if (metaMatch[1] === coverId && !metaMatch[1].includes('/')) return { o, fixed: false }; const esc = xmlEscape(coverId); o = o.replace(/<\w+:?meta\s+name=["']cover["']\s+content=["'][^"']+["']\s*\/?>/gi, `<meta name="cover" content="${esc}" />`); o = o.replace(/<\w+:?meta\s+content=["'][^"']+["']\s+name=["']cover["']\s*\/?>/gi, `<meta name="cover" content="${esc}" />`); return { o, fixed: true }; } const idx = o.indexOf('</metadata>'); if (idx !== -1) return { o: o.substring(0, idx) + ` <meta name="cover" content="${xmlEscape(coverId)}"/>\n </metadata>` + o.substring(idx + 11), fixed: true }; return { o, fixed: false };}
// Apply grayscale to canvas image datafunction applyGrayscale(ctx, width, height) { if (!ENABLE_GRAYSCALE) return; const imageData = ctx.getImageData(0, 0, width, height); const data = imageData.data; for (let i = 0; i < data.length; i += 4) { // Alpha-blend against white background before grayscaling (handles transparent PNGs) const a = data[i + 3] / 255; const blendedR = data[i] * a + 255 * (1 - a); const blendedG = data[i + 1] * a + 255 * (1 - a); const blendedB = data[i + 2] * a + 255 * (1 - a); const gray = Math.round(blendedR * 0.299 + blendedG * 0.587 + blendedB * 0.114); data[i] = gray; data[i + 1] = gray; data[i + 2] = gray; data[i + 3] = 255; } ctx.putImageData(imageData, 0, 0);}
function shouldSkipAutoCrop(imagePath, width, height, ignoreToggle = false) { if (!ignoreToggle && !ENABLE_AUTO_CROP) return true; if (autoCropProtectedPaths.has(imagePath)) return true; if (width < MIN_CROP_DIMENSION || height < MIN_CROP_DIMENSION) return true; return /(^|\/)(cover|thumbnail|thumb|icon)[^/]*\.(jpe?g|png|gif|webp|bmp)$/i.test(imagePath || '');}
function blendedPixel(data, i) { const alpha = data[i + 3] / 255; return { r: data[i] * alpha + 255 * (1 - alpha), g: data[i + 1] * alpha + 255 * (1 - alpha), b: data[i + 2] * alpha + 255 * (1 - alpha) };}
function estimateCropBackground(data, width, height) { const sampleSize = Math.min(CROP_EDGE_SAMPLE_SIZE, Math.floor(width / 8), Math.floor(height / 8)); if (sampleSize < 2) return null;
// Use corners plus edge midpoints so colored margins work without trimming full-bleed art. const samples = []; const points = [ [0, 0], [width - sampleSize, 0], [0, height - sampleSize], [width - sampleSize, height - sampleSize], [Math.floor((width - sampleSize) / 2), 0], [Math.floor((width - sampleSize) / 2), height - sampleSize], [0, Math.floor((height - sampleSize) / 2)], [width - sampleSize, Math.floor((height - sampleSize) / 2)] ];
for (const [startX, startY] of points) { let r = 0, g = 0, b = 0, count = 0; for (let y = startY; y < startY + sampleSize; y++) { const row = y * width * 4; for (let x = startX; x < startX + sampleSize; x++) { const px = blendedPixel(data, row + x * 4); r += px.r; g += px.g; b += px.b; count++; } } samples.push({ r: r / count, g: g / count, b: b / count }); }
const avg = samples.reduce((sum, px) => ({ r: sum.r + px.r, g: sum.g + px.g, b: sum.b + px.b }), { r: 0, g: 0, b: 0 }); avg.r /= samples.length; avg.g /= samples.length; avg.b /= samples.length;
const maxSpread = samples.reduce((spread, px) => Math.max( spread, Math.abs(px.r - avg.r), Math.abs(px.g - avg.g), Math.abs(px.b - avg.b) ), 0);
if (maxSpread > CROP_BACKGROUND_MAX_SPREAD) return null; return avg;}
function isCropContentPixel(px, background) { if (background) { return Math.abs(px.r - background.r) > CROP_BACKGROUND_TOLERANCE || Math.abs(px.g - background.g) > CROP_BACKGROUND_TOLERANCE || Math.abs(px.b - background.b) > CROP_BACKGROUND_TOLERANCE; } return px.r < CROP_WHITE_THRESHOLD || px.g < CROP_WHITE_THRESHOLD || px.b < CROP_WHITE_THRESHOLD;}
function isNearWhiteBackground(background) { return background && background.r >= CROP_WHITE_THRESHOLD && background.g >= CROP_WHITE_THRESHOLD && background.b >= CROP_WHITE_THRESHOLD;}
function findNonWhiteBounds(ctx, width, height) { const imageData = ctx.getImageData(0, 0, width, height); const data = imageData.data; const background = estimateCropBackground(data, width, height); let left = width, top = height, right = -1, bottom = -1;
for (let y = 0; y < height; y++) { const row = y * width * 4; for (let x = 0; x < width; x++) { const i = row + x * 4; const px = blendedPixel(data, i);
if (isCropContentPixel(px, background)) { if (x < left) left = x; if (x > right) right = x; if (y < top) top = y; if (y > bottom) bottom = y; } } }
if (right < left || bottom < top) return null;
left = Math.max(0, left - CROP_PADDING_PX); top = Math.max(0, top - CROP_PADDING_PX); right = Math.min(width - 1, right + CROP_PADDING_PX); bottom = Math.min(height - 1, bottom + CROP_PADDING_PX);
const cropW = right - left + 1; const cropH = bottom - top + 1; const savedRatio = 1 - ((cropW * cropH) / (width * height)); // Colored-background crops need a bigger win to avoid shaving illustrated pages. const minSavedRatio = background && !isNearWhiteBackground(background) ? MIN_COLOR_CROP_SAVINGS_RATIO : MIN_CROP_SAVINGS_RATIO; if (savedRatio < minSavedRatio) return null;
return { x: left, y: top, width: cropW, height: cropH };}
function createAutoCroppedCanvas(img, imagePath, width, height, ignoreToggle = false) { // Build a white-backed source canvas, detect the uniform page/background color // from its edges, crop to pixels that differ from that background, then let the // normal resize/rotate/split pipeline fit that cropped content to the device. // If the image is a cover/icon/small asset, the edges are not uniform, or the // crop would save too little, return the original canvas unchanged. const sourceCanvas = document.createElement('canvas'); sourceCanvas.width = width; sourceCanvas.height = height; const sourceCtx = sourceCanvas.getContext('2d'); sourceCtx.fillStyle = '#FFF'; sourceCtx.fillRect(0, 0, width, height); sourceCtx.drawImage(img, 0, 0);
if (shouldSkipAutoCrop(imagePath, width, height, ignoreToggle)) { return { canvas: sourceCanvas, width, height }; }
const crop = findNonWhiteBounds(sourceCtx, width, height); if (!crop) { return { canvas: sourceCanvas, width, height }; }
const croppedCanvas = document.createElement('canvas'); croppedCanvas.width = crop.width; croppedCanvas.height = crop.height; const croppedCtx = croppedCanvas.getContext('2d'); croppedCtx.fillStyle = '#FFF'; croppedCtx.fillRect(0, 0, crop.width, crop.height); croppedCtx.drawImage(sourceCanvas, crop.x, crop.y, crop.width, crop.height, 0, 0, crop.width, crop.height);
return { canvas: croppedCanvas, width: crop.width, height: crop.height, crop };}
// Process single image - returns array of {data, suffix} objectsconst IMAGE_LOAD_TIMEOUT_MS = 30000; // 30 second timeout for image loadingasync function processImage(data, imageState = 0, imagePath = '') { return new Promise((resolve, reject) => { const url = URL.createObjectURL(new Blob([data])); const img = new Image(); const origSize = data.byteLength; // Set up timeout to handle cases where image never loads const timeoutId = setTimeout(() => { URL.revokeObjectURL(url); reject(new Error('Image load timeout')); }, IMAGE_LOAD_TIMEOUT_MS);
img.onload = async () => { clearTimeout(timeoutId); URL.revokeObjectURL(url); const origW = img.width, origH = img.height; const source = createAutoCroppedCanvas(img, imagePath, origW, origH); const sourceCanvas = source.canvas; const sourceW = source.width; const sourceH = source.height; const sourceWasCropped = !!source.crop;
// imageState: 0=Normal, 1=H-Split (CW/CCW), 2=V-Split, 3=Rotate & Fit // ======================================================================== // STATE 1: H-Split (Rotate + Split) - EXACT COPY FROM index.html // Step 1: Scale WIDTH to 800px (keep aspect ratio) // Step 2: Rotate 90° CW or CCW based on HANDEDNESS // Step 3: If WIDTH > 480, split vertically with overlap // ======================================================================== if (imageState === 1) { // Step 1: Scale WIDTH to 800 (this is the key difference!) const scale = MAX_HEIGHT / sourceW; // 800 / sourceW const scaledW = MAX_HEIGHT; // 800 const scaledH = Math.round(sourceH * scale);
const scaledCanvas = document.createElement('canvas'); scaledCanvas.width = scaledW; scaledCanvas.height = scaledH; const scaledCtx = scaledCanvas.getContext('2d'); scaledCtx.imageSmoothingEnabled = true; scaledCtx.imageSmoothingQuality = 'high'; scaledCtx.fillStyle = '#FFF'; scaledCtx.fillRect(0, 0, scaledW, scaledH); scaledCtx.drawImage(sourceCanvas, 0, 0, sourceW, sourceH, 0, 0, scaledW, scaledH);
// Step 2: Rotate 90° CW or CCW const rotW = scaledH; const rotH = scaledW; // 800
const rotCanvas = document.createElement('canvas'); rotCanvas.width = rotW; rotCanvas.height = rotH; const rotCtx = rotCanvas.getContext('2d'); rotCtx.fillStyle = '#FFF'; rotCtx.fillRect(0, 0, rotW, rotH);
const isClockwise = HANDEDNESS === 'right'; if (isClockwise) { // Rotate 90° CW rotCtx.translate(rotW, 0); rotCtx.rotate(Math.PI / 2); } else { // Rotate 90° CCW rotCtx.translate(0, rotH); rotCtx.rotate(-Math.PI / 2); } rotCtx.drawImage(scaledCanvas, 0, 0); rotCtx.setTransform(1, 0, 0, 1, 0, 0); // Reset transform applyGrayscale(rotCtx, rotW, rotH);
// Step 3: If WIDTH > 480, split vertically if (rotW <= MAX_WIDTH) { const blob = await new Promise(res => rotCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100)); const arrBuf = await blob.arrayBuffer(); resolve({ parts: [{ data: arrBuf, suffix: '', width: rotW, height: rotH, size: arrBuf.byteLength }], meta: { origW, origH, origSize, wasSplit: false, rotated: true, finalW: rotW, finalH: rotH, finalSize: arrBuf.byteLength, imageState: 1 } }); } else { // Split by WIDTH (vertical cuts) - from RIGHT to LEFT for CW, LEFT to RIGHT for CCW const parts = []; const maxW = MAX_WIDTH; // 480 // Centered distribution: calculate numParts first, then distribute evenly let overlapPx, step, numParts; const minOverlapPx = Math.round(maxW * (OVERLAP_PERCENT / 100)); // Configurable overlap const maxStep = maxW - minOverlapPx; numParts = Math.ceil((rotW - minOverlapPx) / maxStep); if (numParts < 2) numParts = 2; // Now calculate step to distribute evenly step = Math.round((rotW - maxW) / (numParts - 1)); overlapPx = maxW - step; // Ensure minimum overlap if (overlapPx < minOverlapPx) { overlapPx = minOverlapPx; step = maxW - overlapPx; }
// Calculate all x positions first to ensure consistency const positions = []; for (let i = 0; i < numParts; i++) { let x; if (isClockwise) { // CW: right to left - start from right edge x = rotW - maxW - (i * step); } else { // CCW: left to right - start from left edge x = i * step; } // Clamp to valid range x = Math.max(0, Math.min(x, rotW - maxW)); positions.push(x); }
// Ensure first and last positions are at edges if (isClockwise) { positions[0] = rotW - maxW; // First part at right edge positions[numParts - 1] = 0; // Last part at left edge } else { positions[0] = 0; // First part at left edge positions[numParts - 1] = rotW - maxW; // Last part at right edge }
for (let i = 0; i < numParts; i++) { const x = positions[i]; const partW = maxW; // Always full width for consistency
const partCanvas = document.createElement('canvas'); partCanvas.width = partW; partCanvas.height = rotH; const partCtx = partCanvas.getContext('2d'); // Clear canvas first partCtx.fillStyle = '#FFFFFF'; partCtx.fillRect(0, 0, partW, rotH); // Draw the slice partCtx.drawImage(rotCanvas, x, 0, partW, rotH, 0, 0, partW, rotH);
const blob = await new Promise(res => partCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100)); const arrBuf = await blob.arrayBuffer(); parts.push({ data: arrBuf, suffix: `_part${i + 1}`, width: partW, height: rotH, size: arrBuf.byteLength }); }
const totalSize = parts.reduce((sum, p) => sum + p.size, 0); resolve({ parts, meta: { origW, origH, origSize, wasSplit: true, splitCount: numParts, rotated: true, finalW: parts[0].width, finalH: parts[0].height, finalSize: totalSize, imageState: 1 } }); } } // ======================================================================== // STATE 2: V-Split (Vertical Split, no rotation) // Step 1: Scale HEIGHT to 800px (up or down) // Step 2: If WIDTH > 480, split vertically with overlap // ======================================================================== else if (imageState === 2) { // ALWAYS scale height to 800 (up or down) const scale = MAX_HEIGHT / sourceH; // 800 / sourceH const scaledW = Math.round(sourceW * scale); const scaledH = MAX_HEIGHT; // Always 800
const scaledCanvas = document.createElement('canvas'); scaledCanvas.width = scaledW; scaledCanvas.height = scaledH; const scaledCtx = scaledCanvas.getContext('2d'); scaledCtx.imageSmoothingEnabled = true; scaledCtx.imageSmoothingQuality = 'high'; scaledCtx.fillStyle = '#FFF'; scaledCtx.fillRect(0, 0, scaledW, scaledH); scaledCtx.drawImage(sourceCanvas, 0, 0, sourceW, sourceH, 0, 0, scaledW, scaledH); applyGrayscale(scaledCtx, scaledW, scaledH);
// Check if split needed if (scaledW <= MAX_WIDTH) { const blob = await new Promise(res => scaledCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100)); const arrBuf = await blob.arrayBuffer(); resolve({ parts: [{ data: arrBuf, suffix: '', width: scaledW, height: scaledH, size: arrBuf.byteLength }], meta: { origW, origH, origSize, wasSplit: false, rotated: false, finalW: scaledW, finalH: scaledH, finalSize: arrBuf.byteLength, imageState: 2 } }); } else { // Split by WIDTH (vertical cuts) - LEFT to RIGHT (natural reading order) const parts = []; const maxW = MAX_WIDTH; // Centered distribution: calculate numParts first, then distribute evenly let overlapPx, step, numParts; const minOverlapPx = Math.round(maxW * (OVERLAP_PERCENT / 100)); // Configurable overlap const maxStep = maxW - minOverlapPx; numParts = Math.ceil((scaledW - minOverlapPx) / maxStep); if (numParts < 2) numParts = 2; // Now calculate step to distribute evenly step = Math.round((scaledW - maxW) / (numParts - 1)); overlapPx = maxW - step; // Ensure minimum overlap if (overlapPx < minOverlapPx) { overlapPx = minOverlapPx; step = maxW - overlapPx; }
// Calculate all x positions first to ensure consistency const positions = []; for (let i = 0; i < numParts; i++) { let x = i * step; // Clamp to valid range x = Math.max(0, Math.min(x, scaledW - maxW)); positions.push(x); } // Ensure last position is at right edge positions[0] = 0; positions[numParts - 1] = scaledW - maxW;
for (let i = 0; i < numParts; i++) { const x = positions[i]; const partW = maxW; // Always full width for consistency
const partCanvas = document.createElement('canvas'); partCanvas.width = partW; partCanvas.height = scaledH; const partCtx = partCanvas.getContext('2d'); // Clear canvas first partCtx.fillStyle = '#FFFFFF'; partCtx.fillRect(0, 0, partW, scaledH); // Draw the slice partCtx.drawImage(scaledCanvas, x, 0, partW, scaledH, 0, 0, partW, scaledH);
const blob = await new Promise(res => partCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100)); const arrBuf = await blob.arrayBuffer(); parts.push({ data: arrBuf, suffix: `_part${i + 1}`, width: partW, height: scaledH, size: arrBuf.byteLength }); }
const totalSize = parts.reduce((sum, p) => sum + p.size, 0); resolve({ parts, meta: { origW, origH, origSize, wasSplit: true, splitCount: numParts, rotated: false, finalW: parts[0].width, finalH: parts[0].height, finalSize: totalSize, imageState: 2 } }); } } // ======================================================================== // STATE 3: Rotate & Fit (Rotate 90°, then scale to fit 480x800, no split) // ======================================================================== else if (imageState === 3) { // Step 1: Rotate 90° based on handedness const rotW = sourceH; const rotH = sourceW;
const rotCanvas = document.createElement('canvas'); rotCanvas.width = rotW; rotCanvas.height = rotH; const rotCtx = rotCanvas.getContext('2d'); rotCtx.fillStyle = '#FFF'; rotCtx.fillRect(0, 0, rotW, rotH);
const isClockwise = HANDEDNESS === 'right'; if (isClockwise) { rotCtx.translate(rotW, 0); rotCtx.rotate(Math.PI / 2); } else { rotCtx.translate(0, rotH); rotCtx.rotate(-Math.PI / 2); } rotCtx.drawImage(sourceCanvas, 0, 0); rotCtx.setTransform(1, 0, 0, 1, 0, 0);
// Step 2: Scale to fit 480x800 (if needed) const fitsInScreen = rotW <= MAX_WIDTH && rotH <= MAX_HEIGHT; if (fitsInScreen && !sourceWasCropped) { // Already fits after rotation - just apply grayscale applyGrayscale(rotCtx, rotW, rotH); const blob = await new Promise(res => rotCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100)); const arrBuf = await blob.arrayBuffer(); resolve({ parts: [{ data: arrBuf, suffix: '', width: rotW, height: rotH, size: arrBuf.byteLength }], meta: { origW, origH, origSize, wasSplit: false, rotated: true, finalW: rotW, finalH: rotH, finalSize: arrBuf.byteLength, imageState: 3 } }); } else { // Scale to fit 480x800 const scale = Math.min(MAX_WIDTH / rotW, MAX_HEIGHT / rotH); const newW = Math.round(rotW * scale); const newH = Math.round(rotH * scale);
const scaledCanvas = document.createElement('canvas'); scaledCanvas.width = newW; scaledCanvas.height = newH; const scaledCtx = scaledCanvas.getContext('2d'); scaledCtx.imageSmoothingEnabled = true; scaledCtx.imageSmoothingQuality = 'high'; scaledCtx.fillStyle = '#FFF'; scaledCtx.fillRect(0, 0, newW, newH); scaledCtx.drawImage(rotCanvas, 0, 0, newW, newH); applyGrayscale(scaledCtx, newW, newH);
const blob = await new Promise(res => scaledCanvas.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100)); const arrBuf = await blob.arrayBuffer(); resolve({ parts: [{ data: arrBuf, suffix: '', width: newW, height: newH, size: arrBuf.byteLength }], meta: { origW, origH, origSize, wasSplit: false, rotated: true, finalW: newW, finalH: newH, finalSize: arrBuf.byteLength, imageState: 3 } }); } } // ======================================================================== // STATE 0: Normal processing (scale to fit, no split/rotation) // ======================================================================== else { // Normal processing: check if scaling is needed const fitsInScreen = sourceW <= MAX_WIDTH && sourceH <= MAX_HEIGHT;
if (fitsInScreen && !sourceWasCropped) { // Image already fits - just convert to JPEG with grayscale const c = document.createElement('canvas'); c.width = sourceW; c.height = sourceH; const ctx = c.getContext('2d'); ctx.fillStyle = '#FFF'; ctx.fillRect(0, 0, sourceW, sourceH); ctx.drawImage(sourceCanvas, 0, 0); applyGrayscale(ctx, sourceW, sourceH);
const blob = await new Promise(res => c.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100)); const arrBuf = await blob.arrayBuffer(); resolve({ parts: [{ data: arrBuf, suffix: '', width: sourceW, height: sourceH, size: arrBuf.byteLength }], meta: { origW, origH, origSize, wasSplit: false, rotated: false, finalW: sourceW, finalH: sourceH, finalSize: arrBuf.byteLength, imageState: 0 } }); } else { // Scale to fit 480x800 const scale = Math.min(MAX_WIDTH / sourceW, MAX_HEIGHT / sourceH); const newW = Math.round(sourceW * scale); const newH = Math.round(sourceH * scale);
const c = document.createElement('canvas'); c.width = newW; c.height = newH; const ctx = c.getContext('2d'); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.fillStyle = '#FFF'; ctx.fillRect(0, 0, newW, newH); ctx.drawImage(sourceCanvas, 0, 0, newW, newH); applyGrayscale(ctx, newW, newH);
const blob = await new Promise(res => c.toBlob(res, 'image/jpeg', JPEG_QUALITY / 100)); const arrBuf = await blob.arrayBuffer(); resolve({ parts: [{ data: arrBuf, suffix: '', width: newW, height: newH, size: arrBuf.byteLength }], meta: { origW, origH, origSize, wasSplit: false, rotated: false, finalW: newW, finalH: newH, finalSize: arrBuf.byteLength, imageState: 0 } }); } } }; img.onerror = () => { clearTimeout(timeoutId); URL.revokeObjectURL(url); reject(new Error('Image load failed')); }; img.src = url; });}
// Convert EPUB file - returns converted blobasync function convertEpubFile(file, progressCallback) { const startTime = Date.now(); const originalSize = file.size;
// Initialize logging clearLog(); showLog(); log(`<strong>${file.name}</strong> <span class="log-detail">(${formatBytes(originalSize)})</span>`, '', 'INFO'); log(`Quality: ${JPEG_QUALITY}% | Overlap: ${OVERLAP_PERCENT}% | Rotation: ${HANDEDNESS === 'right' ? 'CW' : 'CCW'} | Grayscale: ${ENABLE_GRAYSCALE ? 'ON' : 'OFF'} | Auto-crop: ${ENABLE_AUTO_CROP ? 'ON' : 'OFF'}`, '', 'INFO');
const zip = await JSZip.loadAsync(file); const renamed = {}; zip.forEach(p => { const l = p.toLowerCase(); if (l.match(/\.(png|gif|webp|bmp|jpeg)$/)) { renamed[p] = p.replace(/\.(png|gif|webp|bmp|jpeg)$/i, '.jpg'); } });
const out = new JSZip(); const entries = Object.entries(zip.files); const splitImages = {}; const xhtmlFiles = {}; let opfPath = null, opfContent = null; let mainIdentifier = null;
// Write mimetype FIRST per EPUB OCF spec if (zip.files['mimetype']) { const mimetypeData = await zip.files['mimetype'].async('arraybuffer'); out.file('mimetype', mimetypeData, { compression: 'STORE', createFolders: false }); }
// First pass: process images for (let i = 0; i < entries.length; i++) { if (operationCancelled) throw new Error('Cancelled by user'); const [path, fileObj] = entries[i]; if (fileObj.dir || path === 'mimetype') continue; const low = path.toLowerCase();
if (low.match(/\.(png|gif|webp|bmp|jpg|jpeg)$/)) { const data = await fileObj.async('arraybuffer'); const imageState = getImageState(path);
let result; try { result = await processImage(data, imageState, path); } catch (imageError) { // Log error but continue with original image console.error(`Failed to process image ${path}:`, imageError); log(`Warning: Failed to process ${path.split('/').pop()}, using original`, 'warning', 'IMG-ERR');
// Use original image data as fallback result = { parts: [{ data: data, suffix: '', width: 0, height: 0, size: data.byteLength }], meta: { origW: 0, origH: 0, origSize: data.byteLength, wasSplit: false, rotated: false, finalW: 0, finalH: 0, finalSize: data.byteLength, imageState: imageState, processingError: true } }; }
const parts = result.parts; const meta = result.meta;
const baseName = path.replace(/\.[^.]+$/, ''); const newExt = '.jpg';
// Log image processing const imgName = path.split('/').pop(); const origFormat = path.split('.').pop(); logImage(imgName, meta.origW, meta.origH, origFormat, meta.origSize, meta.finalW, meta.finalH, meta.finalSize, meta.wasSplit, meta.splitCount || 0, parts, meta.imageState || 0);
if (parts.length === 1 && parts[0].suffix === '') { const newPath = renamed[path] || path.replace(/\.[^.]+$/, newExt); out.file(newPath, parts[0].data, { compression: 'STORE', createFolders: false }); } else { // Store with full path for collision prevention, but also keep original filename const origName = path.split('/').pop(); const origDir = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : '';
// Key by full path to avoid collisions splitImages[path] = { origName: origName, origDir: origDir, parts: [] };
for (const part of parts) { const partName = baseName.split('/').pop() + part.suffix + newExt; const partPath = (path.includes('/') ? path.substring(0, path.lastIndexOf('/') + 1) : '') + partName; out.file(partPath, part.data, { compression: 'STORE', createFolders: false }); // Store metadata for XHTML/OPF updates splitImages[path].parts.push({ path: partPath, imgName: partName, id: baseName.split('/').pop() + part.suffix, suffix: part.suffix }); } } } else if (low.match(/\.(xhtml|html|htm)$/)) { xhtmlFiles[path] = await safeReadText(fileObj); } else if (low.endsWith('.opf')) { opfPath = path; opfContent = await safeReadText(fileObj); }
if (progressCallback) progressCallback((i / entries.length) * 60); }
// Embedded fonts are dropped entirely — CrossPoint never renders with them. // Counters cover font files, @font-face rules (incl. data: URI fonts), and // encryption.xml shrinkage; they count uncompressed content bytes removed // (the final archive-size delta is reported separately by logSummary). const fontPaths = collectFontPaths(zip, opfContent, opfPath); let removedFontCount = 0; let removedFontBytes = 0; let removedFontFaceRules = 0;
// Second pass: update XHTML using DOMParser for (const [xhtmlPath, content] of Object.entries(xhtmlFiles)) { if (operationCancelled) throw new Error('Cancelled by user'); let t = content;
const stripped = stripComments(t); if (stripped.count) { t = stripped.text; logFix(`Comments (${stripped.count}, ${formatBytes(stripped.bytes)})`, xhtmlPath.split('/').pop()); }
const r = fixSvgCover(t); if (r.fixed) { t = r.c; logFix('SVG cover', xhtmlPath.split('/').pop()); }
const r2 = fixSvgWrappedImages(t); if (r2.fixed) { t = r2.c; logFix(`SVG images (${r2.count})`, xhtmlPath.split('/').pop()); }
// Use DOMParser for all img modifications: remove width/height and handle split images try { const parser = new DOMParser(); const doc = parser.parseFromString(t, 'application/xhtml+xml'); const parseError = doc.querySelector('parsererror');
if (!parseError) { let modified = false;
// Remove width/height attributes from ALL img tags (dimensions may have changed) // This prevents CrossPoint and other readers from using wrong dimensions const allImgElements = doc.querySelectorAll('img'); for (const img of allImgElements) { if (img.hasAttribute('width')) { img.removeAttribute('width'); modified = true; } if (img.hasAttribute('height')) { img.removeAttribute('height'); modified = true; }
const src = img.getAttribute('src'); if (src) { const decodedSrc = decodeHref(src); const resolvedSrc = resolvePath(xhtmlPath, decodedSrc);
const match = Object.entries(renamed).find(([oldPath]) => resolvedSrc === oldPath);
if (match) { const [oldPath, newPath] = match; img.setAttribute('src', decodedSrc.replace(oldPath.split('/').pop(), newPath.split('/').pop())); modified = true; } } }
// Handle split images with path collision prevention if (Object.keys(splitImages).length > 0) { // Get XHTML directory for resolving relative paths const xhtmlDir = xhtmlPath.includes('/') ? xhtmlPath.substring(0, xhtmlPath.lastIndexOf('/')) : ''; const rootFolders = ['ops', 'oebps', 'epub', 'content'];
for (const [fullPath, splitInfo] of Object.entries(splitImages)) { const origName = splitInfo.origName; const origDir = splitInfo.origDir; const parts = splitInfo.parts; const newName = origName.replace(/\.(png|gif|webp|bmp|jpeg)$/i, '.jpg');
// Extract immediate parent directory for collision prevention const splitDirParts = origDir.split('/').filter(p => p); const lastDir = splitDirParts.length > 0 ? splitDirParts[splitDirParts.length - 1].toLowerCase() : null; const immediateParent = (lastDir && !rootFolders.includes(lastDir)) ? splitDirParts[splitDirParts.length - 1] : null;
// Get XHTML's parent directory parts for relative path resolution const xhtmlDirParts = xhtmlDir.split('/').filter(p => p);
// Find all img elements const allImgs = doc.querySelectorAll('img'); const matchingImgs = [];
for (const img of allImgs) { const src = img.getAttribute('src') || ''; const srcParts = src.split('/').filter(p => p && p !== '..' && p !== '.'); const srcName = srcParts.pop() || '';
// Check filename match if (srcName !== origName && srcName !== newName) continue;
// Path collision prevention with root folder handling if (immediateParent) { // Image is in a specific subfolder (not root like OPS/OEBPS) if (srcParts.length === 0) { // src has no path - check if XHTML is in same folder as image const xhtmlLastDir = xhtmlDirParts.length > 0 ? xhtmlDirParts[xhtmlDirParts.length - 1] : null; if (xhtmlLastDir !== immediateParent) continue; } else { // src has path - verify parent directory matches if (srcParts[srcParts.length - 1] !== immediateParent) continue; } } else { // Image is in root folder (like OEBPS/cover.jpg) // Only match if src has NO subfolder path OR points to root folder if (srcParts.length > 0) { const srcLastDir = srcParts[srcParts.length - 1].toLowerCase(); if (!rootFolders.includes(srcLastDir)) continue; } }
matchingImgs.push(img); }
// Process each matching img — Pro's strip+inject approach for (const img of matchingImgs) { const src = img.getAttribute('src') || '';
// Part 1: update src in-place, strip original sizing img.setAttribute('src', src.replace(origName, parts[0].imgName).replace(newName, parts[0].imgName));
if (parts.length > 1) { // Strip original width/height/class that were sized for the unsplit image img.removeAttribute('width'); img.removeAttribute('height'); img.removeAttribute('class'); img.setAttribute('style', 'max-width:100%;height:auto');
// Neutralize container height constraints that were sized for the original let container = img.parentElement; const safeContainers = ['div', 'p', 'figure', 'aside', 'section', 'body']; while (container && !safeContainers.includes(container.tagName.toLowerCase())) container = container.parentElement; const insertTarget = container || img.parentElement; // Strip constraining classes/styles from container — they were for the unsplit image if (insertTarget && insertTarget.tagName.toLowerCase() !== 'body') { insertTarget.removeAttribute('class'); insertTarget.removeAttribute('style'); } const insertParent = insertTarget.parentElement; const insertRef = insertTarget.nextSibling; const ns = doc.documentElement.namespaceURI || 'http://www.w3.org/1999/xhtml';
// Insert new minimal wrappers for parts 2+ in reading order for (let pi = 1; pi < parts.length; pi++) { const wrapper = doc.createElementNS(ns, 'div'); const newImg = doc.createElementNS(ns, 'img'); const partSrc = src.replace(origName, parts[pi].imgName).replace(newName, parts[pi].imgName); newImg.setAttribute('src', partSrc); newImg.setAttribute('alt', ''); newImg.setAttribute('style', 'max-width:100%;height:auto'); wrapper.appendChild(newImg); if (insertRef) insertParent.insertBefore(wrapper, insertRef); else insertParent.appendChild(wrapper); } } modified = true; } } }
// Only serialize if we made changes if (modified) { t = safeSerialize(doc, content); } } } catch (e) { console.warn('DOMParser error for', xhtmlPath, e.message); }
// Strip @font-face rules from inline <style> blocks — embedded font files // are removed, so leftover rules would reference missing resources t = t.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (m, open, css, close) => { const ff = stripFontFaceRules(css); if (!ff.count) return m; removedFontFaceRules += ff.count; removedFontBytes += new TextEncoder().encode(css).length - new TextEncoder().encode(ff.css).length; logFix('@font-face', `${ff.count} rule(s) removed from ${escapeHtml(xhtmlPath.split('/').pop())}`); return open + ff.css + close; });
// Inject universal image constraint — prevents overflow on e-ink displays if (t.includes('</head>')) { t = t.replace('</head>', DEFENSIVE_STYLE + '</head>'); }
out.file(xhtmlPath, t, { compression: 'DEFLATE', compressionOptions: { level: 8 }, createFolders: false }); }
// Extract main identifier from OPF using DOMParser with regex fallback if (opfContent) { mainIdentifier = extractIdentifier(opfContent); }
// Third pass: update OPF using fixOPF (DOMParser with regex fallback) if (opfContent) { let t = opfContent; for (const [o, n] of Object.entries(renamed)) { t = t.split(o.split('/').pop()).join(n.split('/').pop()); } const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/')) : ''; t = fixOPF(t, opfContent, opfDir, splitImages, fontPaths); if (t !== opfContent) logFix('OPF', 'manifest updated'); out.file(opfPath, t, { compression: 'DEFLATE', compressionOptions: { level: 8 }, createFolders: false }); }
// Copy remaining files for (const [path, fileObj] of entries) { if (operationCancelled) throw new Error('Cancelled by user'); if (fileObj.dir || path === 'mimetype') continue; const low = path.toLowerCase(); if (low.match(/\.(png|gif|webp|bmp|jpg|jpeg)$/) || low.match(/\.(xhtml|html|htm)$/) || low.endsWith('.opf')) continue;
if (fontPaths.has(path)) { const fontData = await fileObj.async('arraybuffer'); removedFontCount++; removedFontBytes += fontData.byteLength; log(`${escapeHtml(path.split('/').pop())} <span class="log-detail">(${formatBytes(fontData.byteLength)})</span>`, 'success', 'REMOVE'); continue; }
let data = await fileObj.async('arraybuffer'); if (low.endsWith('.css')) { let t = await safeReadText(fileObj); for (const [o, n] of Object.entries(renamed)) { t = t.split(o.split('/').pop()).join(n.split('/').pop()); } const ff = stripFontFaceRules(t); if (ff.count) { removedFontFaceRules += ff.count; removedFontBytes += new TextEncoder().encode(t).length - new TextEncoder().encode(ff.css).length; t = ff.css; logFix('@font-face', `${ff.count} rule(s) removed from ${escapeHtml(path.split('/').pop())}`); } data = new TextEncoder().encode(t); } else if (path === 'META-INF/encryption.xml' && fontPaths.size) { const t = await safeReadText(fileObj); const enc = stripFontEncryptionEntries(t, fontPaths); if (enc.dropFile) { removedFontBytes += data.byteLength; logFix('encryption.xml', 'removed (contained only font obfuscation entries)'); continue; } if (enc.modified) { const encoded = new TextEncoder().encode(enc.xml); removedFontBytes += Math.max(0, data.byteLength - encoded.length); data = encoded; logFix('encryption.xml', 'font obfuscation entries removed'); } } else if (low.endsWith('.ncx')) { let t = await safeReadText(fileObj); for (const [o, n] of Object.entries(renamed)) { t = t.split(o.split('/').pop()).join(n.split('/').pop()); } const oldT = t; t = syncNCXIdentifier(t, mainIdentifier); if (t !== oldT) logFix('NCX identifier', 'Synced with OPF'); data = new TextEncoder().encode(t); } out.file(path, data, { compression: 'DEFLATE', compressionOptions: { level: 8 }, createFolders: false }); }
if (removedFontCount > 0 || removedFontFaceRules > 0) { log(`Removed ${removedFontCount} embedded font(s) and ${removedFontFaceRules} @font-face rule(s), ${formatBytes(removedFontBytes)} of font data`, '', 'INFO'); }
if (progressCallback) progressCallback(100);
// Generate final blob const newBlob = await out.generateAsync({ type: 'blob', mimeType: 'application/epub+zip' }); const newSize = newBlob.size; const timeElapsed = (Date.now() - startTime) / 1000;
// Log completion log('Conversion complete!', 'success', 'DONE'); logSummary(originalSize, newSize, timeElapsed);
// Auto-export only if NOT in batch mode (batch mode exports at the end) if (!isBatchMode && exportLogCheckbox && exportLogCheckbox.checked) { setTimeout(() => { exportLogToFile(null, false); // isBatch = false for single file }, 100); }
return newBlob;}
// Get WebSocket URL based on current page locationfunction getWsUrl() { const host = window.location.hostname; return `ws://${host}:${WS_PORT}/`;}
// Upload file via WebSocket (faster, binary protocol)function uploadFileWebSocket(file, onProgress, onComplete, onError) { return new Promise((resolve, reject) => { const ws = new WebSocket(getWsUrl()); currentUploadWs = ws; let uploadStarted = false; let sendingChunks = false; let uploadComplete = false; // set only when DONE is received and resolve() called
ws.binaryType = 'arraybuffer';
ws.onopen = function() { console.log('[WS] Connected, starting upload:', file.name); // Send start message: START:<filename>:<size>:<path> ws.send(`START:${file.name}:${file.size}:${currentPath}`); };
ws.onmessage = async function(event) { const msg = event.data; console.log('[WS] Message:', msg);
if (msg === 'READY') { uploadStarted = true; sendingChunks = true;
// Small delay to let connection stabilize await new Promise(r => setTimeout(r, 50));
try { // Send file in chunks const totalSize = file.size; let offset = 0;
while (offset < totalSize && ws.readyState === WebSocket.OPEN) { const chunkSize = Math.min(WS_CHUNK_SIZE, totalSize - offset); const chunk = file.slice(offset, offset + chunkSize); const buffer = await chunk.arrayBuffer();
// Wait for buffer to clear - more aggressive backpressure while (ws.bufferedAmount > WS_CHUNK_SIZE * 2 && ws.readyState === WebSocket.OPEN) { await new Promise(r => setTimeout(r, 5)); }
if (ws.readyState !== WebSocket.OPEN) { throw new Error('WebSocket closed during upload'); }
ws.send(buffer); offset += chunkSize;
// Update local progress with real transfer progress // Server will confirm 100% with DONE message if (onProgress) { onProgress(offset, totalSize); } }
sendingChunks = false; console.log('[WS] All chunks sent, waiting for DONE'); } catch (err) { console.error('[WS] Error sending chunks:', err); sendingChunks = false; ws.close(); reject(err); } } else if (msg.startsWith('PROGRESS:')) { // Server confirmed progress - log for debugging but don't update UI // (local progress is smoother, server progress causes jumping) console.log('[WS] Server progress:', msg); } else if (msg === 'DONE') { // Show 100% when server confirms completion if (onProgress) onProgress(file.size, file.size); uploadComplete = true; currentUploadWs = null; ws.close(); if (onComplete) onComplete(); resolve(); } else if (msg.startsWith('ERROR:')) { const error = msg.substring(6); ws.close(); if (onError) onError(error); reject(new Error(error)); } };
ws.onerror = function(event) { console.error('[WS] Error:', event); currentUploadWs = null; if (!uploadStarted) { reject(new Error('WebSocket connection failed')); } else if (!sendingChunks) { reject(new Error('WebSocket error during upload')); } else { // Error during chunk sending - reject with appropriate message reject(new Error('WebSocket error during file transfer')); } };
ws.onclose = function(event) { console.log('[WS] Connection closed, code:', event.code, 'reason:', event.reason); // Reject for any close before upload was confirmed complete (covers both // mid-chunk-send closes and the "all chunks sent, waiting for DONE" window) if (!uploadComplete) { reject(new Error('WebSocket closed during upload')); } }; });}
// Upload file via HTTP (fallback method)function uploadFileHTTP(file, onProgress, onComplete, onError) { return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', file);
const xhr = new XMLHttpRequest(); currentUploadXhr = xhr; xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
xhr.upload.onprogress = function(e) { if (e.lengthComputable && onProgress) { onProgress(e.loaded, e.total); } };
xhr.onload = function() { currentUploadXhr = null; if (xhr.status === 200) { if (onComplete) onComplete(); resolve(); } else { const error = xhr.responseText || 'Upload failed'; if (onError) onError(error); reject(new Error(error)); } };
xhr.onerror = function() { currentUploadXhr = null; const error = 'Network error'; if (onError) onError(error); reject(new Error(error)); };
xhr.onabort = function() { currentUploadXhr = null; reject(new Error('Upload aborted')); };
xhr.send(formData); });}
async function uploadFile() { if (isUploadInProgress) return;
const fileInput = document.getElementById('fileInput'); const files = Array.from(fileInput.files); const convertEnabled = document.getElementById('convertBeforeUpload').checked;
if (files.length === 0) { alert('Please select at least one file!'); return; }
isUploadInProgress = true; let usedFileNames; try { usedFileNames = await fetchExistingUploadNames(); } catch (error) { isUploadInProgress = false; alert('Failed to check existing files: ' + error.message); return; }
// Prevent modal close during upload uploadGeneration++; const myGeneration = uploadGeneration; document.getElementById('uploadModalClose').classList.add('disabled'); fileInput.disabled = true;
const progressContainer = document.getElementById('progress-container'); const progressFill = document.getElementById('progress-fill'); const progressText = document.getElementById('progress-text'); const uploadBtn = document.getElementById('uploadBtn');
progressContainer.style.display = 'block'; uploadBtn.disabled = true;
let currentIndex = 0; const failedFiles = []; let useWebSocket = true; // Try WebSocket first
// Check if we should use batch logging mode const epubFilesToConvert = files.filter(f => f.name.toLowerCase().endsWith('.epub') && convertEnabled); const useBatchLog = epubFilesToConvert.length > 1 && exportLogCheckbox && exportLogCheckbox.checked;
// Start batch log mode if needed if (useBatchLog) { startBatchLog(epubFilesToConvert.length); showLog(); }
async function uploadNextFile() { if (currentIndex >= files.length) { // All files processed - show summary if (failedFiles.length === 0) { progressFill.style.backgroundColor = '#4caf50'; progressText.textContent = 'All uploads complete!';
// Finalize batch log if in batch mode if (useBatchLog) { finalizeBatchLog(); setTimeout(() => { window.location.reload(); }, 2000); } else { setTimeout(() => { window.location.reload(); }, 1000); } } else { progressFill.style.backgroundColor = '#e74c3c'; const failedList = failedFiles.map(f => f.name).join(', '); progressText.textContent = `${files.length - failedFiles.length}/${files.length} uploaded. Failed: ${failedList}`;
// Add upload errors to batch log if (useBatchLog) { failedFiles.forEach(ff => { logError(`Upload failed for ${ff.name}: ${ff.error}`); }); finalizeBatchLog(); }
// Only show banner if THIS upload session had failures // Use local failedFiles, not the global shared variable if (failedFiles.length > 0) { // Accumulate failed uploads to global (don't replace) failedUploadsGlobal = failedUploadsGlobal.concat(failedFiles); // Clear flag and close modal, then show banner with retry options isUploadInProgress = false; document.getElementById('uploadModalClose').classList.remove('disabled'); closeUploadModal(); showFailedUploadsBanner(); } } return; }
let file = files[currentIndex]; const originalFile = file; // Reset progress bar instantly without transition when starting a new file progressFill.classList.add('no-transition'); progressFill.style.width = '0%'; progressFill.style.backgroundColor = '#27ae60'; // Re-enable transition after a brief delay setTimeout(() => progressFill.classList.remove('no-transition'), 50);
// Check if file is an EPUB and conversion is enabled const isEpub = file.name.toLowerCase().endsWith('.epub'); const needsConversion = isEpub && convertEnabled; let conversionSucceeded = false; let conversionFailed = false; // Track if conversion actually failed let convOriginalSize = 0; // Picked-file size; 0 unless conversion succeeded let convNewSize = 0; // Generated blob size; 0 unless conversion succeeded
if (isEpub && document.getElementById('renameFromMetadataToggle').checked) { const originalName = file.name; progressText.style.color = ''; progressText.textContent = `Reading metadata for ${file.name} (${currentIndex + 1}/${files.length})...`; file = await maybeRenameEbookFile(file); if (file.name !== originalName) { console.log(`[Upload] Renamed from metadata: ${originalName} -> ${file.name}`); } }
const availableName = reserveAvailableUploadFilename(file.name, usedFileNames); if (availableName !== file.name) { console.log(`[Upload] Renamed to avoid collision: ${file.name} -> ${availableName}`); file = new File([file], availableName, { type: file.type, lastModified: file.lastModified }); }
const methodText = useWebSocket ? ' [WS]' : ' [HTTP]'; const stageText = needsConversion ? 'Converting & uploading' : 'Uploading'; progressText.style.color = ''; progressText.textContent = `${stageText} ${file.name} (${currentIndex + 1}/${files.length})${methodText}`;
const onProgress = (loaded, total) => { const uploadPercent = Math.round((loaded / total) * 100); // If conversion succeeded, display goes from 50-100%, otherwise 0-100% const displayPercent = conversionSucceeded ? 50 + Math.round(uploadPercent / 2) : uploadPercent; progressFill.style.width = displayPercent + '%'; const prefix = conversionSucceeded ? 'Converting & uploading' : 'Uploading'; progressText.textContent = `${prefix} ${file.name} (${currentIndex + 1}/${files.length})${methodText} — ${uploadPercent}%`; };
const onComplete = () => { // Save file log to batch if in batch mode and this file was converted // Consider it successful only if conversion didn't fail if (useBatchLog && needsConversion) { const ok = !conversionFailed && conversionSucceeded; saveToFileBatchLog(file.name, ok, ok ? convOriginalSize : 0, ok ? convNewSize : 0); }
currentIndex++; uploadNextFile(); };
const onError = (error) => { // Save failed file log to batch if in batch mode if (useBatchLog && needsConversion) { logError(`Upload failed: ${error}`); // Preserve conversion size totals when the convert step succeeded but // the upload failed; otherwise nothing was produced and 0/0 is correct. saveToFileBatchLog(file.name, false, convOriginalSize, convNewSize); }
failedFiles.push({ name: file.name, error: error, file: originalFile });
// If network error, mark all remaining files as failed and show retry banner if (error.includes('connection failed') || error.includes('Network error') || error.includes('timeout') || error.includes('disconnected')) { console.log(`[Network] Network error detected: ${error}`);
// Add all remaining files to failed list const remainingFiles = files.slice(currentIndex + 1); remainingFiles.forEach(remainingFile => { failedFiles.push({ name: remainingFile.name, error: 'Network error - upload interrupted', file: remainingFile }); });
// Show retry banner immediately with all failed files failedUploadsGlobal = failedUploadsGlobal.concat(failedFiles); isUploadInProgress = false; document.getElementById('uploadModalClose').classList.remove('disabled'); closeUploadModal(); showFailedUploadsBanner(); return; // Stop processing }
currentIndex++; uploadNextFile(); };
try { // Convert EPUB if needed if (needsConversion) { progressFill.style.backgroundColor = '#9b59b6'; // Purple for conversion progressText.textContent = `Converting ${file.name} (${currentIndex + 1}/${files.length})...`;
// Clear log for single file mode, or just add separator for batch mode if (!useBatchLog) { clearLog(); showLog(); }
// Snapshot before the `file =` reassignment below, which swaps in the // converted blob and makes file.size point at the optimised size. const origFileSize = file.size;
try { const convertedBlob = await convertEpubFile(file, (percent) => { // Pass current quality setting to converter progressFill.style.width = (percent * 0.5) + '%'; // Conversion takes first 50% });
// Create new File from converted blob file = new File([convertedBlob], file.name, { type: 'application/epub+zip' }); progressFill.style.backgroundColor = '#27ae60'; // Back to green for upload conversionSucceeded = true; convOriginalSize = origFileSize; convNewSize = convertedBlob.size; } catch (convError) { if (operationCancelled) { if (uploadGeneration === myGeneration) restoreAfterCancel(); return; } console.error('Conversion error:', convError); // Log the error logError(`Conversion failed: ${convError.message}`); log('Uploading original file instead...', 'warning', 'INFO'); conversionFailed = true;
// In single file mode, export error log if (!useBatchLog && exportLogCheckbox && exportLogCheckbox.checked) { setTimeout(() => { exportLogToFile(null, false); // isBatch = false for single file }, 100); }
// If conversion fails, try uploading original file progressText.textContent = `Conversion failed, uploading original ${file.name}...`; progressFill.style.backgroundColor = '#e67e22'; // Orange for fallback // Reset progress bar to 0% for original file upload progressFill.style.width = '0%'; } }
if (useWebSocket) { await uploadFileWebSocket(file, onProgress, null, null); } else { await uploadFileHTTP(file, onProgress, null, null); } // Ensure progress bar shows 100% before moving to next file progressFill.style.width = '100%'; progressText.textContent = `Upload complete: ${file.name}`; onComplete(); } catch (error) { if (operationCancelled) { if (uploadGeneration === myGeneration) restoreAfterCancel(); return; } console.error('Upload error:', error); // Log upload error if conversion succeeded but upload failed if (conversionSucceeded) { logError(`Upload failed: ${error.message}`); }
if (useWebSocket && error.message === 'WebSocket connection failed') { // Fall back to HTTP for all subsequent uploads console.log('WebSocket failed, falling back to HTTP'); useWebSocket = false; // Retry this file with HTTP try { await uploadFileHTTP(file, onProgress, null, null); onComplete(); } catch (httpError) { onError(httpError.message); } } else { onError(error.message); } } }
uploadNextFile();}
function showFailedUploadsBanner() { const banner = document.getElementById('failedUploadsBanner'); const filesList = document.getElementById('failedFilesList');
filesList.innerHTML = '';
failedUploadsGlobal.forEach((failedFile, index) => { const item = document.createElement('div'); item.className = 'failed-file-item'; item.innerHTML = ` <div class="failed-file-info"> <div class="failed-file-name">📄 ${escapeHtml(failedFile.name)}</div> <div class="failed-file-error">Error: ${escapeHtml(failedFile.error)}</div> </div> <button class="retry-btn" onclick="retrySingleUpload(${index})">Retry</button> `; filesList.appendChild(item); });
// Ensure retry all button is visible const retryAllBtn = banner.querySelector('.retry-all-btn'); if (retryAllBtn) retryAllBtn.style.display = '';
banner.classList.add('show');}
function dismissFailedUploads() { const banner = document.getElementById('failedUploadsBanner'); banner.classList.remove('show'); failedUploadsGlobal = [];}
function retrySingleUpload(index) { const failedFile = failedUploadsGlobal[index]; if (!failedFile) return;
// Create a DataTransfer to set the file input const dt = new DataTransfer(); dt.items.add(failedFile.file);
const fileInput = document.getElementById('fileInput'); fileInput.files = dt.files;
// Remove this file from failed list failedUploadsGlobal.splice(index, 1);
// If no more failed files, hide banner if (failedUploadsGlobal.length === 0) { dismissFailedUploads(); }
// Open modal and trigger upload openUploadModal(); validateFile();}
function retryAllFailedUploads() { if (failedUploadsGlobal.length === 0) return;
// Create a DataTransfer with all failed files const dt = new DataTransfer(); failedUploadsGlobal.forEach(failedFile => { dt.items.add(failedFile.file); });
const fileInput = document.getElementById('fileInput'); fileInput.files = dt.files;
// Clear failed files list failedUploadsGlobal = []; dismissFailedUploads();
// Open modal and trigger upload openUploadModal(); validateFile();}
function createFolder() { const folderName = document.getElementById('folderName').value.trim();
if (!folderName) { alert('Please enter a folder name!'); return; }
// Validate folder name const validName = /^(?!\.{1,2}$)[^"*:<>?\/\\|]+$/.test(folderName); if (!validName) { alert('Folder name cannot contain \" * : < > ? / \\ | and must not be . or ..'); return; }
const formData = new FormData(); formData.append('name', folderName); formData.append('path', currentPath);
const xhr = new XMLHttpRequest(); xhr.open('POST', '/mkdir', true);
xhr.onload = function() { if (xhr.status === 200) { window.location.reload(); } else { alert('Failed to create folder: ' + xhr.responseText); } };
xhr.onerror = function() { alert('Failed to create folder - network error'); };
xhr.send(formData); }
// Rename functions function openRenameModal(name, path) { document.getElementById('renameItemName').textContent = '📄 ' + name; document.getElementById('renameItemPath').value = path; document.getElementById('renameNewName').value = name; document.getElementById('renameModal').classList.add('open'); setTimeout(() => { const input = document.getElementById('renameNewName'); input.focus(); input.select(); }, 50); }
function closeRenameModal() { document.getElementById('renameModal').classList.remove('open'); }
function confirmRename() { const path = document.getElementById('renameItemPath').value; const newName = document.getElementById('renameNewName').value.trim();
if (!newName) { alert('Please enter a new name.'); return; } if (newName.includes('/') || newName.includes('\\')) { alert('File name cannot include slashes.'); return; }
const formData = new FormData(); formData.append('path', path); formData.append('name', newName);
const xhr = new XMLHttpRequest(); xhr.open('POST', '/rename', true);
xhr.onload = function() { if (xhr.status === 200) { window.location.reload(); } else { alert('Failed to rename: ' + xhr.responseText); } closeRenameModal(); };
xhr.onerror = function() { alert('Failed to rename - network error'); closeRenameModal(); };
xhr.send(formData); }
// Move functions function normalizePath(path) { if (!path) return '/'; let normalized = path.trim(); if (!normalized.startsWith('/')) normalized = '/' + normalized; if (normalized.length > 1 && normalized.endsWith('/')) { normalized = normalized.slice(0, -1); } return normalized; }
function getParentPath(path) { const normalized = normalizePath(path); if (normalized === '/') return '/'; const idx = normalized.lastIndexOf('/'); return idx <= 0 ? '/' : normalized.slice(0, idx); }
async function loadMoveFolderOptions() { const options = new Set(); options.add('/'); const parent = getParentPath(currentPath); if (parent) options.add(parent);
async function fetchFolders(path) { try { const response = await fetch('/api/files?path=' + encodeURIComponent(path)); if (!response.ok) return []; return await response.json(); } catch (e) { return []; } }
const rootFiles = await fetchFolders('/'); rootFiles.forEach(file => { if (file.isDirectory) { options.add('/' + file.name); } });
if (currentPath !== '/') { const currentFiles = await fetchFolders(currentPath); currentFiles.forEach(file => { if (file.isDirectory) { let folderPath = currentPath; if (!folderPath.endsWith('/')) folderPath += '/'; folderPath += file.name; options.add(folderPath); } }); }
const dataList = document.getElementById('moveFolderOptions'); dataList.innerHTML = ''; Array.from(options).sort().forEach(path => { const option = document.createElement('option'); option.value = path; dataList.appendChild(option); }); }
function openMoveModal(name, path) { document.getElementById('moveItemName').textContent = '📄 ' + name; document.getElementById('moveItemPath').value = path; document.getElementById('moveDestPath').value = currentPath === '/' ? '/' : currentPath; document.getElementById('moveModal').classList.add('open'); loadMoveFolderOptions(); setTimeout(() => { document.getElementById('moveDestPath').focus(); }, 50); }
function closeMoveModal() { document.getElementById('moveModal').classList.remove('open'); }
function confirmMove() { const path = document.getElementById('moveItemPath').value; const destPath = normalizePath(document.getElementById('moveDestPath').value);
if (!destPath) { alert('Please enter a destination folder.'); return; }
const formData = new FormData(); formData.append('path', path); formData.append('dest', destPath);
const xhr = new XMLHttpRequest(); xhr.open('POST', '/move', true);
xhr.onload = function() { if (xhr.status === 200) { window.location.reload(); } else { alert('Failed to move: ' + xhr.responseText); } closeMoveModal(); };
xhr.onerror = function() { alert('Failed to move - network error'); closeMoveModal(); };
xhr.send(formData); } hydrate();</script></body></html>