search.cljs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. (ns electron.search
  2. "Provides both page level and block level index"
  3. (:require ["path" :as path]
  4. ["fs-extra" :as fs]
  5. ["better-sqlite3" :as sqlite3]
  6. [clojure.string :as string]
  7. ["electron" :refer [app]]
  8. [electron.logger :as logger]))
  9. (defonce databases (atom nil))
  10. (defn close!
  11. []
  12. (when @databases
  13. (doseq [[_ database] @databases]
  14. (.close database))
  15. (reset! databases nil)))
  16. (defn sanitize-db-name
  17. [db-name]
  18. (-> db-name
  19. (string/replace "/" "_")
  20. (string/replace "\\" "_")
  21. (string/replace ":" "_"))) ;; windows
  22. (defn get-db
  23. [repo]
  24. (get @databases (sanitize-db-name repo)))
  25. (defn prepare
  26. [^object db sql]
  27. (when db
  28. (.prepare db sql)))
  29. (defn add-blocks-fts-triggers!
  30. "Table bindings of blocks tables and the blocks FTS virtual tables"
  31. [db]
  32. (let [triggers [;; add
  33. "CREATE TRIGGER IF NOT EXISTS blocks_ad AFTER DELETE ON blocks
  34. BEGIN
  35. DELETE from blocks_fts where rowid = old.id;
  36. END;"
  37. ;; insert
  38. "CREATE TRIGGER IF NOT EXISTS blocks_ai AFTER INSERT ON blocks
  39. BEGIN
  40. INSERT INTO blocks_fts (rowid, uuid, content, page)
  41. VALUES (new.id, new.uuid, new.content, new.page);
  42. END;"
  43. ;; update
  44. "CREATE TRIGGER IF NOT EXISTS blocks_au AFTER UPDATE ON blocks
  45. BEGIN
  46. DELETE from blocks_fts where rowid = old.id;
  47. INSERT INTO blocks_fts (rowid, uuid, content, page)
  48. VALUES (new.id, new.uuid, new.content, new.page);
  49. END;"]]
  50. (doseq [trigger triggers]
  51. (let [stmt (prepare db trigger)]
  52. (.run ^object stmt)))))
  53. (defn add-pages-fts-triggers!
  54. "Table bindings of pages tables and the pages FTS virtual tables"
  55. [db]
  56. (let [triggers [;; add
  57. "CREATE TRIGGER IF NOT EXISTS pages_ad AFTER DELETE ON pages
  58. BEGIN
  59. DELETE from pages_fts where rowid = old.id;
  60. END;"
  61. ;; insert
  62. "CREATE TRIGGER IF NOT EXISTS pages_ai AFTER INSERT ON pages
  63. BEGIN
  64. INSERT INTO pages_fts (rowid, uuid, content)
  65. VALUES (new.id, new.uuid, new.content);
  66. END;"
  67. ;; update
  68. "CREATE TRIGGER IF NOT EXISTS pages_au AFTER UPDATE ON pages
  69. BEGIN
  70. DELETE from pages_fts where rowid = old.id;
  71. INSERT INTO pages_fts (rowid, uuid, content)
  72. VALUES (new.id, new.uuid, new.content);
  73. END;"]]
  74. (doseq [trigger triggers]
  75. (let [stmt (prepare db trigger)]
  76. (.run ^object stmt)))))
  77. (defn create-blocks-table!
  78. [db]
  79. (let [stmt (prepare db "CREATE TABLE IF NOT EXISTS blocks (
  80. id INTEGER PRIMARY KEY,
  81. uuid TEXT NOT NULL,
  82. content TEXT NOT NULL,
  83. page INTEGER)")]
  84. (.run ^object stmt)))
  85. (defn create-blocks-fts-table!
  86. [db]
  87. (let [stmt (prepare db "CREATE VIRTUAL TABLE IF NOT EXISTS blocks_fts USING fts5(uuid, content, page)")]
  88. (.run ^object stmt)))
  89. (defn create-pages-table!
  90. [db]
  91. (let [stmt (prepare db "CREATE TABLE IF NOT EXISTS pages (
  92. id INTEGER PRIMARY KEY,
  93. uuid TEXT NOT NULL,
  94. content TEXT NOT NULL)")]
  95. (.run ^object stmt)))
  96. (defn create-pages-fts-table!
  97. [db]
  98. (let [stmt (prepare db "CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(uuid, content)")]
  99. (.run ^object stmt)))
  100. (defn get-search-dir
  101. []
  102. (let [path (.getPath ^object app "userData")]
  103. (path/join path "search")))
  104. (defn ensure-search-dir!
  105. []
  106. (fs/ensureDirSync (get-search-dir)))
  107. (defn get-db-full-path
  108. [db-name]
  109. (let [db-name (sanitize-db-name db-name)
  110. search-dir (get-search-dir)]
  111. [db-name (path/join search-dir db-name)]))
  112. (defn get-db-path
  113. "Search cache paths"
  114. [db-name]
  115. (let [db-name (sanitize-db-name db-name)
  116. search-dir (get-search-dir)]
  117. [db-name (path/join search-dir db-name)]))
  118. (defn open-db!
  119. [db-name]
  120. (let [[db-sanitized-name db-full-path] (get-db-full-path db-name)]
  121. (try (let [db (sqlite3 db-full-path nil)]
  122. (create-blocks-table! db)
  123. (create-blocks-fts-table! db)
  124. (create-pages-table! db)
  125. (create-pages-fts-table! db)
  126. (add-blocks-fts-triggers! db)
  127. (add-pages-fts-triggers! db)
  128. (swap! databases assoc db-sanitized-name db))
  129. (catch :default e
  130. (logger/error (str e ": " db-name))
  131. (fs/unlinkSync db-full-path)))))
  132. (defn open-dbs!
  133. []
  134. (let [search-dir (get-search-dir)
  135. dbs (fs/readdirSync search-dir)
  136. dbs (remove (fn [file-name] (.startsWith file-name ".")) dbs)]
  137. (when (seq dbs)
  138. (doseq [db-name dbs]
  139. (open-db! db-name)))))
  140. (defn- clj-list->sql
  141. "Turn clojure list into SQL list
  142. '(1 2 3 4)
  143. ->
  144. \"('1','2','3','4')\""
  145. [ids]
  146. (str "(" (->> (map (fn [id] (str "'" id "'")) ids)
  147. (string/join ", ")) ")"))
  148. (defn upsert-pages!
  149. [repo pages]
  150. (if-let [db (get-db repo)]
  151. ;; TODO: what if a CONFLICT on uuid
  152. (let [insert (prepare db "INSERT INTO pages (id, uuid, content) VALUES (@id, @uuid, @content) ON CONFLICT (id) DO UPDATE SET content = @content")
  153. insert-many (.transaction ^object db
  154. (fn [pages]
  155. (doseq [page pages]
  156. (.run ^object insert page))))]
  157. (insert-many pages))
  158. (do
  159. (open-db! repo)
  160. (upsert-pages! repo pages))))
  161. (defn delete-pages!
  162. [repo ids]
  163. (when-let [db (get-db repo)]
  164. (let [sql (str "DELETE from pages WHERE id IN " (clj-list->sql ids))
  165. stmt (prepare db sql)]
  166. (.run ^object stmt))))
  167. (defn upsert-blocks!
  168. [repo blocks]
  169. (if-let [db (get-db repo)]
  170. ;; TODO: what if a CONFLICT on uuid
  171. (let [insert (prepare db "INSERT INTO blocks (id, uuid, content, page) VALUES (@id, @uuid, @content, @page) ON CONFLICT (id) DO UPDATE SET content = @content")
  172. insert-many (.transaction ^object db
  173. (fn [blocks]
  174. (doseq [block blocks]
  175. (.run ^object insert block))))]
  176. (insert-many blocks))
  177. (do
  178. (open-db! repo)
  179. (upsert-blocks! repo blocks))))
  180. (defn delete-blocks!
  181. [repo ids]
  182. (when-let [db (get-db repo)]
  183. (let [sql (str "DELETE from blocks WHERE id IN " (clj-list->sql ids))
  184. stmt (prepare db sql)]
  185. (.run ^object stmt))))
  186. ;; (defn search-blocks-fts
  187. ;; [q]
  188. ;; (when-not (string/blank? q)
  189. ;; (let [stmt (prepare @database
  190. ;; "select id, uuid, content from blocks_fts where content match ? ORDER BY rank")]
  191. ;; (js->clj (.all ^object stmt q) :keywordize-keys true))))
  192. (defn- search-blocks-aux
  193. [database sql input page limit]
  194. (let [stmt (prepare database sql)]
  195. (js->clj
  196. (if page
  197. (.all ^object stmt (int page) input limit)
  198. (.all ^object stmt input limit))
  199. :keywordize-keys true)))
  200. (defn- get-match-inputs
  201. [q]
  202. (let [match-input (-> q
  203. (string/replace " and " " AND ")
  204. (string/replace " & " " AND ")
  205. (string/replace " or " " OR ")
  206. (string/replace " | " " OR ")
  207. (string/replace " not " " NOT "))]
  208. (if (not= q match-input)
  209. [(string/replace match-input "," "")]
  210. [q
  211. (str "\"" match-input "\"")])))
  212. (defn distinct-by
  213. [f col]
  214. (reduce
  215. (fn [acc x]
  216. (if (some #(= (f x) (f %)) acc)
  217. acc
  218. (vec (conj acc x))))
  219. []
  220. col))
  221. (defn search-blocks
  222. ":page - the page to specificly search on"
  223. [repo q {:keys [limit page]}]
  224. (when-let [database (get-db repo)]
  225. (when-not (string/blank? q)
  226. (let [match-inputs (get-match-inputs q)
  227. non-match-input (str "%" (string/replace q #"\s+" "%") "%")
  228. limit (or limit 20)
  229. select "select rowid, uuid, content, page from blocks_fts where "
  230. pg-sql (if page "page = ? and" "")
  231. match-sql (str select
  232. pg-sql
  233. " content match ? order by rank limit ?")
  234. non-match-sql (str select
  235. pg-sql
  236. " content like ? limit ?")
  237. matched-result (->>
  238. (map
  239. (fn [match-input]
  240. (search-blocks-aux database match-sql match-input page limit))
  241. match-inputs)
  242. (apply concat))]
  243. (->>
  244. (concat matched-result
  245. (search-blocks-aux database non-match-sql non-match-input page limit))
  246. (distinct-by :id)
  247. (take limit)
  248. (vec))))))
  249. (defn- search-pages-res-unpack
  250. [arr]
  251. (let [[rowid uuid content snippet] arr]
  252. {:id rowid
  253. :uuid uuid
  254. :content content
  255. :snippet snippet}))
  256. (defn- search-pages-aux
  257. [database sql input limit]
  258. (let [stmt (prepare database sql)]
  259. (map search-pages-res-unpack (-> (.raw ^object stmt)
  260. (.all input limit)
  261. (js->clj)))))
  262. (defn search-pages
  263. [repo q {:keys [limit]}]
  264. (when-let [database (get-db repo)]
  265. (when-not (string/blank? q)
  266. (let [match-inputs (get-match-inputs q)
  267. non-match-input (str "%" (string/replace q #"\s+" "%") "%")
  268. limit (or limit 20)
  269. ;; https://www.sqlite.org/fts5.html#the_highlight_function
  270. ;; the 2nd column in pages_fts (content)
  271. ;; pfts_2lqh is a key for retrieval
  272. ;; highlight and snippet only works for some matching with high rank
  273. snippet-aux "snippet(pages_fts, 1, '$pfts_2lqh>$', '$<pfts_2lqh$', '...', 32)"
  274. select (str "select rowid, uuid, content, " snippet-aux " from pages_fts where ")
  275. match-sql (str select
  276. " content match ? order by rank limit ?")
  277. non-match-sql (str select
  278. " content like ? limit ?")
  279. matched-result (->>
  280. (map
  281. (fn [match-input]
  282. (search-pages-aux database match-sql match-input limit))
  283. match-inputs)
  284. (apply concat))]
  285. (->>
  286. (concat matched-result
  287. (search-pages-aux database non-match-sql non-match-input limit))
  288. (distinct-by :id)
  289. (take limit)
  290. (vec))))))
  291. (defn truncate-blocks-table!
  292. [repo]
  293. (when-let [database (get-db repo)]
  294. (let [stmt (prepare database
  295. "delete from blocks;")
  296. _ (.run ^object stmt)
  297. stmt (prepare database
  298. "delete from blocks_fts;")]
  299. (.run ^object stmt))))
  300. (defn truncate-pages-table!
  301. [repo]
  302. (when-let [database (get-db repo)]
  303. (let [stmt (prepare database
  304. "delete from pages;")
  305. _ (.run ^object stmt)
  306. stmt (prepare database
  307. "delete from pages_fts;")]
  308. (.run ^object stmt))))
  309. (defn delete-db!
  310. [repo]
  311. (when-let [database (get-db repo)]
  312. (.close database)
  313. (let [[db-name db-full-path] (get-db-path repo)]
  314. (logger/info "Delete search indice: " db-full-path)
  315. (fs/unlinkSync db-full-path)
  316. (swap! databases dissoc db-name))))
  317. (defn query
  318. [repo sql]
  319. (when-let [database (get-db repo)]
  320. (let [stmt (prepare database sql)]
  321. (.all ^object stmt))))