Minor change to oclc lookup heuristic.
[quanlib.git] / store.rb
1
2 require 'csv'
3 require 'fileutils'
4 require 'inifile'
5 require 'pg'
6
7 require 'series'
8
9 class Store
10   def unclassified_csv
11     @basePath + '/csv/unclassified.csv'
12   end
13
14   def initialize(config_file)
15     @conn = nil
16
17     config = IniFile.load(config_file)
18     if nil == config
19       puts 'FATAL:  Failed to load config file "' + config_file + '".  Aborting initialization.'
20       return
21     end
22
23     section = config['database']
24     @dbhost = section['host']
25     @dbport = 5432
26     @dbname = section['name']
27     @dbuser = section['user']
28     @dbpass = section['pass']
29
30     section = config['filesystem']
31     @basePath = section['basePath']
32   end
33
34   def connect
35     # @conn = PGconn.connect('localhost', 5432, '', '', 'quanlib', 'quanlib', 'quanlib')
36     @conn = PG.connect(@dbhost, @dbport, '', '', @dbname, @dbuser, @dbpass)
37     return @conn
38   end
39
40   def disconnect
41     @conn.close()
42   end
43
44   def construct_efs_path(efs_id)
45     id_str = sprintf('%010d', efs_id)
46     path = sprintf('%s/%s/%s/%s', id_str[0,2], id_str[2,2], id_str[4,2], id_str[6,2])
47     name = id_str + '.dat'
48     return path, name
49   end
50
51   def create_schema(skip_class)
52     create_authors = 
53 <<EOS
54       CREATE TABLE Authors (
55         id          INTEGER PRIMARY KEY,
56         grouping    VARCHAR(64),
57         reading     VARCHAR(256),
58         sort        VARCHAR(256)
59       );
60 EOS
61
62     create_books = 
63 <<EOS
64       CREATE TABLE Books (
65         id             INTEGER PRIMARY KEY,
66         author         INTEGER REFERENCES Authors(id),
67         classification INTEGER REFERENCES Classifications(id),
68         cover          INTEGER,
69         description    TEXT,
70         path           VARCHAR(256),
71         series         INTEGER REFERENCES Series(id),
72         title          VARCHAR(256),
73         volume         VARCHAR(16)
74       );
75 EOS
76
77     create_classification =
78 <<EOS
79       CREATE TABLE Classifications (
80         id              INTEGER PRIMARY KEY,
81         ddc             VARCHAR(32),
82         lcc             VARCHAR(32),
83         author_grouping VARCHAR(64),
84         author_sort     VARCHAR(128),
85         title_grouping  VARCHAR(256),
86         title           VARCHAR(256)
87       );
88 EOS
89
90     create_efs = 
91 <<EOS
92       CREATE TABLE EFS (
93         id          INTEGER,
94         mimetype    VARCHAR(64)
95       );
96 EOS
97
98     create_fast = 
99 <<EOS
100       CREATE TABLE FAST (
101         id          VARCHAR(32) PRIMARY KEY,
102         descr       VARCHAR(128)
103       );
104 EOS
105
106     # Associative entity, linking FAST and Classifications tables
107     # in a 0..n to 0..m relationship
108     create_fast_classifications =
109 <<EOS
110       CREATE TABLE FAST_Classifications (
111         fast           VARCHAR(32) REFERENCES FAST(id),
112         classification INTEGER REFERENCES Classifications(id)
113       );
114 EOS
115
116     create_series = 
117 <<EOS
118       CREATE TABLE Series (
119         id          INTEGER PRIMARY KEY,
120         age         VARCHAR(32),
121         genre       VARCHAR(32),
122         grouping    VARCHAR(64),
123         code        VARCHAR(16),
124         descr       VARCHAR(128)
125       )
126 EOS
127
128     stmts = [
129       create_authors,
130       create_classification,
131       create_efs,
132       create_fast,
133       create_series,
134       create_books,
135       create_fast_classifications,
136       'CREATE SEQUENCE author_id;',
137       'CREATE SEQUENCE book_id;',
138       'CREATE SEQUENCE classification_id;',
139       'CREATE SEQUENCE efs_id;',
140       'CREATE SEQUENCE series_id;'
141     ]
142
143     for stmt in stmts
144       @conn.exec(stmt)
145     end
146
147     if skip_class == false
148       populate_fast_table()
149       populate_classifications_table()
150     end
151
152     populate_series_table()
153
154   end
155
156   def dropSchema
157     stmts = [
158       'DROP TABLE Books;',
159       'DROP TABLE FAST_Classifications;',
160       'DROP TABLE Authors;',
161       'DROP TABLE Classifications;',
162       'DROP TABLE EFS;',
163       'DROP TABLE FAST;',
164       'DROP TABLE Series;',
165       'DROP SEQUENCE author_id;',
166       'DROP SEQUENCE book_id;',
167       'DROP SEQUENCE classification_id;',
168       'DROP SEQUENCE efs_id;',
169       'DROP SEQUENCE series_id;'
170     ]
171
172     for stmt in stmts do
173       begin
174         @conn.exec(stmt)
175       rescue Exception => exc
176         puts 'WARNING:  "' + stmt + '" failed:  ' + exc.to_s
177       end
178     end
179   end
180
181   def find_author(author)
182     sqlSelect = "SELECT id FROM Authors WHERE grouping=$1 AND reading=$2 AND sort=$3;"
183     args = [author.grouping, author.reading_order, author.sort_order]
184     @conn.exec_params(sqlSelect, args) do |rs|
185       if rs.ntuples > 0
186         return rs[0]['id']
187       end
188     end
189     return nil
190   end
191
192   def init_db(skip_class)
193     sql = "SELECT 1 FROM pg_tables WHERE tableowner='quanlib' AND tablename='books'"
194     found = false
195     @conn.exec(sql).each do |row|
196       found = true
197     end
198
199     if ! found
200       create_schema(skip_class)
201     end
202   end
203
204   def load_author(id)
205     sqlSelect = "SELECT grouping, reading, sort FROM Authors WHERE id=$1"
206     args = [id]
207     @conn.exec_params(sqlSelect, args) do |rs|
208       if rs.ntuples != 1
209         raise "Expected 1 row for " + id + " but got " + rs.ntuples + ":  " + sqlSelect
210       end
211       row = rs[0]
212       author = Author.new(row['grouping'], row['reading'], row['sort'])
213       return author
214     end
215     return nil
216   end
217
218   def store_author(author)
219     id = find_author(author)
220     if nil == id
221       id = next_id('author_id')
222       sqlInsert = "INSERT INTO Authors(id, grouping, reading, sort) VALUES ($1, $2, $3, $4);"
223       args = [id, author.grouping, author.reading_order, author.sort_order]
224       begin 
225         rs = @conn.exec_params(sqlInsert, args)
226       rescue Exception => e
227         puts sqlInsert + ":  " + args.inspect()
228         puts e.message
229         puts $@
230       ensure
231         rs.clear if rs
232       end
233     end
234     return id
235   end
236
237   def load_book(id)
238     sql = "SELECT author, classification, cover, description, path, series, title, volume FROM Books WHERE id=$1;"
239     book = nil
240
241     begin
242       @conn.exec_params(sql, [id]) do |rs|
243         if 1 != rs.ntuples
244           raise 'Expected one row in Books for id ' + id + ', but found ' + rs.length + '.'
245           return nil
246         end
247         row = rs[0]
248
249         book = Book.new(self)
250         book.author = load_author(row['author'])
251         book.classification_id = row['classification']
252         book.cover = load_cover(row['cover'])
253         book.description = row['description']
254         book.path = row['path']
255         book.series_id = row['series']
256         book.title = row['title']
257         book.volume = row['volume']
258       end    
259     rescue Exception => e
260       puts sql + ": " + id
261       puts e.message
262       puts $@
263     end
264
265     return book
266   end
267
268   def store_book(book)
269     sql = "INSERT INTO Books (id, author, classification, cover, description, path, series, title, volume) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);"
270
271     book_id = next_id('book_id')
272
273     author_id = store_author(book.author)
274     (efs_id, mime_type) = store_cover(book)
275
276     args = [book_id, author_id, book.classification_id, efs_id, book.description(), book.path(), book.series_id(), book.title(), book.volume()]
277
278     begin
279       rs = @conn.exec_params(sql, args)
280     rescue Exception => e
281       puts sql + ": " + args.inspect()
282       puts e.message 
283       puts $@
284     ensure
285       rs.clear if rs
286     end
287
288     return book_id
289   end
290
291   def find_classification(author_grouping, title_grouping)
292     #puts 'find_classification("' + author_grouping.inspect + '", "' + title_grouping.inspect + '")...'
293     sql = "SELECT id FROM Classifications WHERE author_grouping = $1 AND title_grouping = $2;"
294     @conn.exec_params(sql, [author_grouping, title_grouping]) do |rs|
295       if rs.ntuples > 0
296         #puts '  --> ' + rs[0]['id'].inspect
297         return rs[0]['id']
298       end
299     end
300     #puts '  --> NIL'
301     return nil
302   end
303
304   def load_classification(id)
305     sql  = "SELECT ddc, lcc, author_grouping, author_sort, title_grouping, title "
306     sql += " FROM Classifications WHERE id=$1"
307     @conn.exec_params(sql, [id]) do |rs|
308       if rs.ntuples > 0
309         row = rs[0]
310         ddc = row['ddc']
311         lcc = row['lcc']
312         author_grouping = row['author_grouping']
313         author = row['author_sort']
314         title_grouping = row['title_grouping']
315         title = row['title']
316
317         result = Classification.new(ddc, lcc, author_grouping, author, title_grouping, title)
318         result.id = id
319         return result
320       end
321     end
322
323     return nil
324   end
325
326   def load_cover(id)
327     if nil == id
328       return nil
329     end
330
331     mime_type = 'application/octet-stream'
332
333     sql = "SELECT mimeType FROM Efs WHERE id=$1"
334     @conn.exec_params(sql, [id]) do |rs|
335       if rs.ntuples != 1
336         raise "Expected one row but got " + rs.ntuples + ": " + sql + ": " + id
337       end
338       mime_type = rs[0]['mimeType']
339     end
340
341     (efspath, efsname) = construct_efs_path(id)
342
343     fullpath = @basePath + '/efs/' + efspath + '/' + efsname
344
345     return Cover.new(nil, fullpath, mime_type)
346
347     #File.open(fullpath, 'rb') do |is|
348     #  return Cover.new(is, fullpath, mime_type)
349     #end
350     #
351     #return nil
352   end
353
354   def store_cover(book)
355     efs_id = nil
356     cover = book.cover()
357
358     if nil == cover
359       return nil
360     end
361
362     @conn.exec("SELECT nextval('efs_id')") do |rs|
363       efs_id = rs[0]['nextval']
364     end
365
366     if nil == efs_id
367       return nil
368     end
369
370     (efspath, efsname) = construct_efs_path(efs_id)
371
372     efspath = @basePath + '/efs/' + efspath
373
374     FileUtils.mkdir_p(efspath)
375
376     (filepath, mimetype) = cover.write_image(efspath, efsname)
377
378     sql = "INSERT INTO efs VALUES ($1, $2)"
379     begin
380       rs = @conn.exec_params(sql, [efs_id, mimetype])
381     rescue Exception => e
382       puts sql + ": " + efs_id + ", " + mimetype
383       puts e.message
384       puts $@
385     ensure
386       rs.clear if rs
387     end
388     
389     return efs_id, mimetype
390   end
391
392   def exec_id_query(sql, args)
393     ids = []
394     @conn.exec_params(sql, args) do |rs|
395       rs.each do |row|
396         ids.push(row['id'])
397       end
398     end
399     return ids
400   end
401
402   def exec_update(sql, args)
403     begin
404       rs = @conn.exec_params(sql, args)
405     rescue Exception => e
406       puts sql + ": " + args.inspect()
407       puts e.message
408       puts $@
409     ensure
410       rs.clear if rs
411     end
412   end
413
414   def next_id(seq_name)
415     id = nil
416     @conn.exec("SELECT nextval('" + seq_name + "');") do |rs|
417       id = rs[0]['nextval']
418     end 
419     return id
420   end
421
422   def get_series(grouping, code)
423     if nil == code
424       return nil
425     end
426
427     sql = "SELECT id FROM Series WHERE grouping=$1 AND code=$2;"
428     args = [grouping, code]
429     @conn.exec_params(sql, args).each do |row|
430       return row['id']
431     end
432
433     # TODO:  Create a new series object here?
434     puts 'WARNING:  series("' + grouping + '", "' + code + '") not found.'
435     return nil
436   end
437
438   def load_series(id)
439     sql = "SELECT age,genre,grouping,code,descr FROM Series WHERE id=$1;"
440     args = [id]
441     @conn.exec_params(sql, args) do |rs|
442       if rs.ntuples > 0
443         row = rs[0]
444         series = Series.new(id)
445         series.age = row['age']
446         series.genre = row['genre']
447         series.grouping = row['grouping']
448         series.code = row['code']
449         series.descr = row['descr']
450         return series
451       end
452     end
453     return nil
454   end
455
456   def populate_classifications_table
457     puts "Populating the Classifications table..."
458     first = true
459     CSV.foreach(@basePath + '/csv/class.csv') do |row|
460       if first
461         # skip the header row
462         first = false
463       else
464
465         # First, add a row to the Classifications table
466
467         id = next_id('classification_id')
468         ddc = row[0]
469         lcc = row[1]
470         author_grouping = row[2]
471         author_sort = row[3]
472         title_grouping = row[4]
473         title = row[5]
474         
475         sqlInsert = "INSERT INTO Classifications (id, ddc, lcc, author_grouping, author_sort, title_grouping, title) VALUES ($1, $2, $3, $4, $5, $6, $7);"
476         args = [id, ddc, lcc, author_grouping, author_sort, title_grouping, title]
477         exec_update(sqlInsert, args)
478
479         # Second, link up with the appropriate FAST table entries
480
481         fast = []
482         input = row[6]
483         if input.length > 0
484           fast = input.split(';')
485         end 
486
487         fast.each do |fast_id|
488           sqlInsert = "INSERT INTO FAST_Classifications (fast, classification) VALUES ($1, $2);"
489           args = [fast_id, id]
490           exec_update(sqlInsert, args)
491         end
492       end
493     end
494   end
495
496   def populate_fast_table
497     puts "Populating the FAST table..."
498     first = true
499     CSV.foreach(@basePath + '/csv/fast.csv') do |row|
500       if first
501         first = false   # skip the header row
502       else
503         id = row[0]
504         descr = row[1]
505         sqlInsert = "INSERT INTO FAST (id, descr) VALUES ($1, $2);"
506         exec_update(sqlInsert, [id, descr])
507       end
508     end
509   end
510
511   def populate_series_table
512     puts "Populating the Series table..."
513     CSV.foreach(@basePath + '/csv/series.csv') do |row|
514       id = next_id('series_id')
515       sqlInsert = "INSERT INTO Series (id, age, genre, grouping, code, descr) VALUES ($1, $2, $3, $4, $5, $6);"
516       args = [id] + row
517       exec_update(sqlInsert, args)
518     end
519   end
520
521   def query_books_by_author(pattern)
522     sql = 
523 <<EOS
524       SELECT b.id FROM Authors a 
525       INNER JOIN Books b ON b.author=a.id 
526       LEFT OUTER JOIN Series s on s.id=b.series
527       WHERE upper(a.grouping) LIKE $1 
528       ORDER BY a.grouping, b.series, b.volume, b.title
529 EOS
530     return exec_id_query(sql, [pattern])
531   end
532
533   def query_books_by_ddc
534     sql = 
535 <<EOS
536       SELECT b.id FROM Classifications c 
537       INNER JOIN Books b ON b.classification=c.id
538       ORDER BY c.ddc
539 EOS
540     return exec_id_query(sql, [])
541   end
542
543   def query_books_by_series_id(id)
544     sql = 
545 <<EOS
546       SELECT b.id FROM Books b
547       WHERE b.series = $1
548       ORDER BY b.volume,b.title
549 EOS
550     return exec_id_query(sql, [id])
551   end
552
553   def query_series_by_age(pattern)
554     sql = 
555 <<EOS
556       SELECT s.id 
557       FROM Series s
558       WHERE s.age LIKE $1
559       ORDER BY s.grouping,s.descr
560 EOS
561     return exec_id_query(sql, [pattern])
562   end
563 end
564