entry.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package storage // import "miniflux.app/v2/internal/storage"
  4. import (
  5. "database/sql"
  6. "errors"
  7. "fmt"
  8. "log/slog"
  9. "time"
  10. "miniflux.app/v2/internal/crypto"
  11. "miniflux.app/v2/internal/model"
  12. "github.com/lib/pq"
  13. )
  14. // ErrEntryTombstoned is returned when an entry cannot be created because its
  15. // (feed_id, hash) pair has a tombstone recording a prior deletion.
  16. var ErrEntryTombstoned = errors.New("store: entry is tombstoned")
  17. // CountAllEntries returns the number of entries for each status in the database.
  18. func (s *Storage) CountAllEntries() (map[string]int64, error) {
  19. rows, err := s.db.Query(`SELECT status, count(*) FROM entries GROUP BY status`)
  20. if err != nil {
  21. return nil, fmt.Errorf("storage: unable to count entries: %w", err)
  22. }
  23. defer rows.Close()
  24. results := make(map[string]int64)
  25. results[model.EntryStatusUnread] = 0
  26. results[model.EntryStatusRead] = 0
  27. for rows.Next() {
  28. var status string
  29. var count int64
  30. if err := rows.Scan(&status, &count); err != nil {
  31. continue
  32. }
  33. results[status] = count
  34. }
  35. results["total"] = results[model.EntryStatusUnread] + results[model.EntryStatusRead]
  36. return results, nil
  37. }
  38. // NewEntryQueryBuilder returns a new EntryQueryBuilder
  39. func (s *Storage) NewEntryQueryBuilder(userID int64) *EntryQueryBuilder {
  40. return NewEntryQueryBuilder(s, userID)
  41. }
  42. // UpdateEntryTitleAndContent updates entry title and content.
  43. func (s *Storage) UpdateEntryTitleAndContent(entry *model.Entry) error {
  44. truncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)
  45. query := `
  46. UPDATE
  47. entries
  48. SET
  49. title=$1,
  50. content=$2,
  51. reading_time=$3,
  52. document_vectors = setweight(to_tsvector($4), 'A') || setweight(to_tsvector($5), 'B')
  53. WHERE
  54. id=$6 AND user_id=$7
  55. `
  56. if _, err := s.db.Exec(
  57. query,
  58. entry.Title,
  59. entry.Content,
  60. entry.ReadingTime,
  61. truncatedTitle,
  62. truncatedContent,
  63. entry.ID,
  64. entry.UserID); err != nil {
  65. return fmt.Errorf(`store: unable to update entry #%d: %v`, entry.ID, err)
  66. }
  67. return nil
  68. }
  69. // createEntry add a new entry.
  70. func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
  71. truncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)
  72. // The WHERE NOT EXISTS guard makes the tombstone check atomic with the insert, so a
  73. // concurrent archive committing between an earlier existence check and this statement
  74. // cannot bring a deleted entry back as unread.
  75. query := `
  76. INSERT INTO entries
  77. (
  78. title,
  79. hash,
  80. url,
  81. comments_url,
  82. published_at,
  83. content,
  84. author,
  85. user_id,
  86. feed_id,
  87. reading_time,
  88. changed_at,
  89. document_vectors,
  90. tags
  91. )
  92. SELECT
  93. $1,
  94. $2,
  95. $3,
  96. $4,
  97. $5,
  98. $6,
  99. $7,
  100. $8,
  101. $9,
  102. $10,
  103. now(),
  104. setweight(to_tsvector($11), 'A') || setweight(to_tsvector($12), 'B'),
  105. $13
  106. WHERE NOT EXISTS (
  107. SELECT 1 FROM entry_tombstones WHERE feed_id=$9 AND hash=$2
  108. )
  109. RETURNING
  110. id, status, created_at, changed_at
  111. `
  112. err := tx.QueryRow(
  113. query,
  114. entry.Title,
  115. entry.Hash,
  116. entry.URL,
  117. entry.CommentsURL,
  118. entry.Date,
  119. entry.Content,
  120. entry.Author,
  121. entry.UserID,
  122. entry.FeedID,
  123. entry.ReadingTime,
  124. truncatedTitle,
  125. truncatedContent,
  126. pq.Array(entry.Tags),
  127. ).Scan(
  128. &entry.ID,
  129. &entry.Status,
  130. &entry.CreatedAt,
  131. &entry.ChangedAt,
  132. )
  133. if errors.Is(err, sql.ErrNoRows) {
  134. return ErrEntryTombstoned
  135. }
  136. if err != nil {
  137. return fmt.Errorf(`store: unable to create entry %q (feed #%d): %v`, entry.URL, entry.FeedID, err)
  138. }
  139. for _, enclosure := range entry.Enclosures {
  140. enclosure.EntryID = entry.ID
  141. enclosure.UserID = entry.UserID
  142. err := s.createEnclosure(tx, enclosure)
  143. if err != nil {
  144. return err
  145. }
  146. }
  147. return nil
  148. }
  149. // updateEntry updates an entry when a feed is refreshed.
  150. // Note: we do not update the published date because some feeds do not contains any date,
  151. // it default to time.Now() which could change the order of items on the history page.
  152. func (s *Storage) updateEntry(tx *sql.Tx, entry *model.Entry) error {
  153. truncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)
  154. query := `
  155. UPDATE
  156. entries
  157. SET
  158. title=$1,
  159. url=$2,
  160. comments_url=$3,
  161. content=$4,
  162. author=$5,
  163. reading_time=$6,
  164. document_vectors = setweight(to_tsvector($7), 'A') || setweight(to_tsvector($8), 'B'),
  165. tags=$12
  166. WHERE
  167. user_id=$9 AND feed_id=$10 AND hash=$11
  168. RETURNING
  169. id
  170. `
  171. err := tx.QueryRow(
  172. query,
  173. entry.Title,
  174. entry.URL,
  175. entry.CommentsURL,
  176. entry.Content,
  177. entry.Author,
  178. entry.ReadingTime,
  179. truncatedTitle,
  180. truncatedContent,
  181. entry.UserID,
  182. entry.FeedID,
  183. entry.Hash,
  184. pq.Array(entry.Tags),
  185. ).Scan(&entry.ID)
  186. if err != nil {
  187. return fmt.Errorf(`store: unable to update entry %q: %v`, entry.URL, err)
  188. }
  189. for _, enclosure := range entry.Enclosures {
  190. enclosure.UserID = entry.UserID
  191. enclosure.EntryID = entry.ID
  192. }
  193. return s.updateEnclosures(tx, entry)
  194. }
  195. // entryExists checks if an entry already exists based on its hash when refreshing a feed.
  196. func (s *Storage) entryExists(tx *sql.Tx, entry *model.Entry) (bool, error) {
  197. var result bool
  198. // Note: This query uses entries_feed_id_hash_key index (filtering on user_id is not necessary).
  199. err := tx.QueryRow(`SELECT true FROM entries WHERE feed_id=$1 AND hash=$2 LIMIT 1`, entry.FeedID, entry.Hash).Scan(&result)
  200. if err != nil && err != sql.ErrNoRows {
  201. return result, fmt.Errorf(`store: unable to check if entry exists: %v`, err)
  202. }
  203. return result, nil
  204. }
  205. func (s *Storage) getEntryIDByHash(tx *sql.Tx, feedID int64, entryHash string) (int64, error) {
  206. var entryID int64
  207. err := tx.QueryRow(
  208. `SELECT id FROM entries WHERE feed_id=$1 AND hash=$2 LIMIT 1`,
  209. feedID,
  210. entryHash,
  211. ).Scan(&entryID)
  212. if errors.Is(err, sql.ErrNoRows) {
  213. return 0, nil
  214. }
  215. if err != nil {
  216. return 0, fmt.Errorf(`store: unable to fetch entry ID: %v`, err)
  217. }
  218. return entryID, nil
  219. }
  220. // InsertEntryForFeed inserts a single entry into a feed, optionally updating if it already exists.
  221. // Returns true if a new entry was created, false if an existing one was reused.
  222. func (s *Storage) InsertEntryForFeed(userID, feedID int64, entry *model.Entry) (bool, error) {
  223. entry.UserID = userID
  224. entry.FeedID = feedID
  225. tx, err := s.db.Begin()
  226. if err != nil {
  227. return false, fmt.Errorf("store: unable to start transaction: %v", err)
  228. }
  229. defer tx.Rollback()
  230. entryID, err := s.getEntryIDByHash(tx, entry.FeedID, entry.Hash)
  231. if err != nil {
  232. return false, err
  233. }
  234. alreadyExistingEntry := entryID > 0
  235. if alreadyExistingEntry {
  236. entry.ID = entryID
  237. } else {
  238. if err := s.createEntry(tx, entry); err != nil {
  239. return false, err
  240. }
  241. }
  242. if err := tx.Commit(); err != nil {
  243. return false, err
  244. }
  245. return !alreadyExistingEntry, nil
  246. }
  247. func (s *Storage) IsNewEntry(feedID int64, entryHash string) bool {
  248. // An entry is new only if it is neither stored nor tombstoned; otherwise
  249. // callers (such as the crawler) would do expensive work on every refresh
  250. // for items that will be discarded.
  251. query := `
  252. SELECT
  253. EXISTS (
  254. SELECT 1 FROM entries WHERE feed_id=$1 AND hash=$2
  255. ) OR EXISTS (
  256. SELECT 1 FROM entry_tombstones WHERE feed_id=$1 AND hash=$2
  257. )
  258. `
  259. var known bool
  260. s.db.QueryRow(query, feedID, entryHash).Scan(&known)
  261. return !known
  262. }
  263. func (s *Storage) GetReadTime(feedID int64, entryHash string) int {
  264. var result int
  265. // Note: This query uses entries_feed_id_hash_key index
  266. s.db.QueryRow(
  267. `SELECT
  268. reading_time
  269. FROM
  270. entries
  271. WHERE
  272. feed_id=$1 AND
  273. hash=$2
  274. `,
  275. feedID,
  276. entryHash,
  277. ).Scan(&result)
  278. return result
  279. }
  280. // RefreshFeedEntries updates feed entries while refreshing a feed.
  281. func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries, updateExistingEntries bool) (newEntries model.Entries, err error) {
  282. for _, entry := range entries {
  283. entry.UserID = userID
  284. entry.FeedID = feedID
  285. tx, err := s.db.Begin()
  286. if err != nil {
  287. return nil, fmt.Errorf(`store: unable to start transaction: %v`, err)
  288. }
  289. entryExists, err := s.entryExists(tx, entry)
  290. if err != nil {
  291. if rollbackErr := tx.Rollback(); rollbackErr != nil {
  292. return nil, fmt.Errorf(`store: unable to rollback transaction: %v (rolled back due to: %v)`, rollbackErr, err)
  293. }
  294. return nil, err
  295. }
  296. if entryExists {
  297. if updateExistingEntries {
  298. err = s.updateEntry(tx, entry)
  299. }
  300. } else {
  301. err = s.createEntry(tx, entry)
  302. switch {
  303. case errors.Is(err, ErrEntryTombstoned):
  304. err = nil
  305. case err == nil:
  306. newEntries = append(newEntries, entry)
  307. }
  308. }
  309. if err != nil {
  310. if rollbackErr := tx.Rollback(); rollbackErr != nil {
  311. return nil, fmt.Errorf(`store: unable to rollback transaction: %v (rolled back due to: %v)`, rollbackErr, err)
  312. }
  313. return nil, err
  314. }
  315. if err := tx.Commit(); err != nil {
  316. return nil, fmt.Errorf(`store: unable to commit transaction: %v`, err)
  317. }
  318. }
  319. return newEntries, nil
  320. }
  321. // ArchiveEntries deletes entries older than the given interval and records tombstones so they are not re-ingested.
  322. func (s *Storage) ArchiveEntries(status string, interval time.Duration, limit int) (int64, error) {
  323. if interval < 0 || limit <= 0 {
  324. return 0, nil
  325. }
  326. query := `
  327. WITH to_delete AS (
  328. SELECT id, feed_id, hash
  329. FROM entries
  330. WHERE
  331. status=$1 AND
  332. starred is false AND
  333. share_code='' AND
  334. created_at < now() - $2::interval
  335. ORDER BY created_at ASC
  336. FOR UPDATE SKIP LOCKED
  337. LIMIT $3
  338. ), deleted AS (
  339. DELETE FROM entries
  340. USING to_delete
  341. WHERE entries.id = to_delete.id
  342. RETURNING entries.feed_id, entries.hash
  343. )
  344. INSERT INTO entry_tombstones (feed_id, hash)
  345. SELECT feed_id, hash FROM deleted WHERE hash <> ''
  346. ON CONFLICT (feed_id, hash) DO NOTHING
  347. `
  348. days := max(int(interval/(24*time.Hour)), 1)
  349. result, err := s.db.Exec(query, status, fmt.Sprintf("%d days", days), limit)
  350. if err != nil {
  351. return 0, fmt.Errorf(`store: unable to archive %s entries: %v`, status, err)
  352. }
  353. count, err := result.RowsAffected()
  354. if err != nil {
  355. return 0, fmt.Errorf(`store: unable to get the number of rows affected: %v`, err)
  356. }
  357. return count, nil
  358. }
  359. // SetEntriesStatus update the status of the given list of entries.
  360. func (s *Storage) SetEntriesStatus(userID int64, entryIDs []int64, status string) error {
  361. query := `
  362. UPDATE
  363. entries
  364. SET
  365. status=$1,
  366. changed_at=now()
  367. WHERE
  368. user_id=$2 AND
  369. id=ANY($3)
  370. `
  371. if _, err := s.db.Exec(query, status, userID, pq.Array(entryIDs)); err != nil {
  372. return fmt.Errorf(`store: unable to update entries statuses %v: %v`, entryIDs, err)
  373. }
  374. return nil
  375. }
  376. // SetEntriesStatusAndCountVisible updates the status of the given entries and returns how many are visible in global views.
  377. func (s *Storage) SetEntriesStatusAndCountVisible(userID int64, entryIDs []int64, status string) (int, error) {
  378. query := `
  379. WITH updated AS (
  380. UPDATE entries
  381. SET
  382. status=$1,
  383. changed_at=now()
  384. WHERE
  385. user_id=$2 AND
  386. id=ANY($3)
  387. RETURNING feed_id
  388. )
  389. SELECT count(*)
  390. FROM updated u
  391. JOIN feeds f ON (f.id = u.feed_id)
  392. JOIN categories c ON (c.id = f.category_id)
  393. WHERE NOT f.hide_globally AND NOT c.hide_globally
  394. `
  395. var visible int
  396. if err := s.db.QueryRow(query, status, userID, pq.Array(entryIDs)).Scan(&visible); err != nil {
  397. return 0, fmt.Errorf(`store: unable to update entries status %v: %v`, entryIDs, err)
  398. }
  399. return visible, nil
  400. }
  401. // SetEntriesStarredState updates the starred state for the given list of entries.
  402. func (s *Storage) SetEntriesStarredState(userID int64, entryIDs []int64, starred bool) error {
  403. query := `UPDATE entries SET starred=$1, changed_at=now() WHERE user_id=$2 AND id=ANY($3)`
  404. result, err := s.db.Exec(query, starred, userID, pq.Array(entryIDs))
  405. if err != nil {
  406. return fmt.Errorf(`store: unable to update the starred state %v: %v`, entryIDs, err)
  407. }
  408. count, err := result.RowsAffected()
  409. if err != nil {
  410. return fmt.Errorf(`store: unable to update these entries %v: %v`, entryIDs, err)
  411. }
  412. if count == 0 {
  413. return errors.New(`store: nothing has been updated`)
  414. }
  415. return nil
  416. }
  417. // ToggleStarred toggles entry starred value.
  418. func (s *Storage) ToggleStarred(userID int64, entryID int64) error {
  419. query := `UPDATE entries SET starred = NOT starred, changed_at=now() WHERE user_id=$1 AND id=$2`
  420. result, err := s.db.Exec(query, userID, entryID)
  421. if err != nil {
  422. return fmt.Errorf(`store: unable to toggle starred flag for entry #%d: %v`, entryID, err)
  423. }
  424. count, err := result.RowsAffected()
  425. if err != nil {
  426. return fmt.Errorf(`store: unable to toggle starred flag for entry #%d: %v`, entryID, err)
  427. }
  428. if count == 0 {
  429. return errors.New(`store: nothing has been updated`)
  430. }
  431. return nil
  432. }
  433. // FlushHistory deletes all read entries (non-starred, non-shared) and records tombstones to prevent re-ingestion.
  434. func (s *Storage) FlushHistory(userID int64) error {
  435. query := `
  436. WITH deleted AS (
  437. DELETE FROM entries
  438. WHERE user_id=$1 AND status=$2 AND starred is false AND share_code=''
  439. RETURNING feed_id, hash
  440. )
  441. INSERT INTO entry_tombstones (feed_id, hash)
  442. SELECT feed_id, hash FROM deleted WHERE hash <> ''
  443. ON CONFLICT (feed_id, hash) DO NOTHING
  444. `
  445. if _, err := s.db.Exec(query, userID, model.EntryStatusRead); err != nil {
  446. return fmt.Errorf(`store: unable to flush history: %v`, err)
  447. }
  448. return nil
  449. }
  450. // MarkAllAsRead updates all user entries to the read status.
  451. func (s *Storage) MarkAllAsRead(userID int64) error {
  452. query := `UPDATE entries SET status=$1, changed_at=now() WHERE user_id=$2 AND status=$3`
  453. result, err := s.db.Exec(query, model.EntryStatusRead, userID, model.EntryStatusUnread)
  454. if err != nil {
  455. return fmt.Errorf(`store: unable to mark all entries as read: %v`, err)
  456. }
  457. count, _ := result.RowsAffected()
  458. slog.Debug("Marked all entries as read",
  459. slog.Int64("user_id", userID),
  460. slog.Int64("nb_entries", count),
  461. )
  462. return nil
  463. }
  464. // MarkAllAsReadBeforeDate updates all user entries to the read status before the given date.
  465. func (s *Storage) MarkAllAsReadBeforeDate(userID int64, before time.Time) error {
  466. query := `
  467. UPDATE
  468. entries
  469. SET
  470. status=$1,
  471. changed_at=now()
  472. WHERE
  473. user_id=$2 AND status=$3 AND published_at < $4
  474. `
  475. result, err := s.db.Exec(query, model.EntryStatusRead, userID, model.EntryStatusUnread, before)
  476. if err != nil {
  477. return fmt.Errorf(`store: unable to mark all entries as read before %s: %v`, before.Format(time.RFC3339), err)
  478. }
  479. count, _ := result.RowsAffected()
  480. slog.Debug("Marked all entries as read before date",
  481. slog.Int64("user_id", userID),
  482. slog.Int64("nb_entries", count),
  483. slog.String("before", before.Format(time.RFC3339)),
  484. )
  485. return nil
  486. }
  487. // MarkGloballyVisibleFeedsAsRead updates all user entries to the read status.
  488. func (s *Storage) MarkGloballyVisibleFeedsAsRead(userID int64) error {
  489. query := `
  490. UPDATE
  491. entries
  492. SET
  493. status=$1,
  494. changed_at=now()
  495. FROM
  496. feeds
  497. WHERE
  498. entries.feed_id = feeds.id
  499. AND entries.user_id=$2
  500. AND entries.status=$3
  501. AND feeds.hide_globally=$4
  502. `
  503. result, err := s.db.Exec(query, model.EntryStatusRead, userID, model.EntryStatusUnread, false)
  504. if err != nil {
  505. return fmt.Errorf(`store: unable to mark globally visible feeds as read: %v`, err)
  506. }
  507. count, _ := result.RowsAffected()
  508. slog.Debug("Marked globally visible feed entries as read",
  509. slog.Int64("user_id", userID),
  510. slog.Int64("nb_entries", count),
  511. )
  512. return nil
  513. }
  514. // MarkFeedAsRead updates all feed entries to the read status.
  515. func (s *Storage) MarkFeedAsRead(userID, feedID int64, before time.Time) error {
  516. query := `
  517. UPDATE
  518. entries
  519. SET
  520. status=$1,
  521. changed_at=now()
  522. WHERE
  523. user_id=$2 AND feed_id=$3 AND status=$4 AND published_at < $5
  524. `
  525. result, err := s.db.Exec(query, model.EntryStatusRead, userID, feedID, model.EntryStatusUnread, before)
  526. if err != nil {
  527. return fmt.Errorf(`store: unable to mark feed entries as read: %v`, err)
  528. }
  529. count, _ := result.RowsAffected()
  530. slog.Debug("Marked feed entries as read",
  531. slog.Int64("user_id", userID),
  532. slog.Int64("feed_id", feedID),
  533. slog.Int64("nb_entries", count),
  534. slog.String("before", before.Format(time.RFC3339)),
  535. )
  536. return nil
  537. }
  538. // MarkCategoryAsRead updates all category entries to the read status.
  539. func (s *Storage) MarkCategoryAsRead(userID, categoryID int64, before time.Time) error {
  540. query := `
  541. UPDATE
  542. entries
  543. SET
  544. status=$1,
  545. changed_at=now()
  546. FROM
  547. feeds
  548. WHERE
  549. feed_id=feeds.id
  550. AND
  551. feeds.user_id=$2
  552. AND
  553. status=$3
  554. AND
  555. published_at < $4
  556. AND
  557. feeds.category_id=$5
  558. `
  559. result, err := s.db.Exec(query, model.EntryStatusRead, userID, model.EntryStatusUnread, before, categoryID)
  560. if err != nil {
  561. return fmt.Errorf(`store: unable to mark category entries as read: %v`, err)
  562. }
  563. count, _ := result.RowsAffected()
  564. slog.Debug("Marked category entries as read",
  565. slog.Int64("user_id", userID),
  566. slog.Int64("category_id", categoryID),
  567. slog.Int64("nb_entries", count),
  568. slog.String("before", before.Format(time.RFC3339)),
  569. )
  570. return nil
  571. }
  572. // EntryShareCode returns the share code of the provided entry.
  573. // It generates a new one if not already defined.
  574. func (s *Storage) EntryShareCode(userID int64, entryID int64) (shareCode string, err error) {
  575. query := `SELECT share_code FROM entries WHERE user_id=$1 AND id=$2`
  576. err = s.db.QueryRow(query, userID, entryID).Scan(&shareCode)
  577. if err != nil {
  578. err = fmt.Errorf(`store: unable to get share code for entry #%d: %v`, entryID, err)
  579. return
  580. }
  581. if shareCode == "" {
  582. shareCode = crypto.GenerateRandomStringHex(20)
  583. query = `UPDATE entries SET share_code = $1 WHERE user_id=$2 AND id=$3`
  584. _, err = s.db.Exec(query, shareCode, userID, entryID)
  585. if err != nil {
  586. err = fmt.Errorf(`store: unable to set share code for entry #%d: %v`, entryID, err)
  587. return
  588. }
  589. }
  590. return
  591. }
  592. // UnshareEntry removes the share code for the given entry.
  593. func (s *Storage) UnshareEntry(userID int64, entryID int64) (err error) {
  594. query := `UPDATE entries SET share_code='' WHERE user_id=$1 AND id=$2`
  595. _, err = s.db.Exec(query, userID, entryID)
  596. if err != nil {
  597. err = fmt.Errorf(`store: unable to remove share code for entry #%d: %v`, entryID, err)
  598. }
  599. return
  600. }
  601. func truncateTitleAndContentForTSVectorField(title, content string) (string, string) {
  602. // The length of a tsvector (lexemes + positions) must be less than 1 megabyte.
  603. // We don't need to index the entire content, and we need to keep a buffer for the positions.
  604. return truncateStringForTSVectorField(title, 200000), truncateStringForTSVectorField(content, 500000)
  605. }
  606. // truncateStringForTSVectorField truncates a string and don't break UTF-8 characters.
  607. func truncateStringForTSVectorField(s string, maxSize int) string {
  608. if len(s) < maxSize {
  609. return s
  610. }
  611. // Truncate to fit under the limit, ensuring we don't break UTF-8 characters
  612. truncated := s[:maxSize-1]
  613. // Walk backwards to find the last complete UTF-8 character
  614. for i := len(truncated) - 1; i >= 0; i-- {
  615. if (truncated[i] & 0x80) == 0 {
  616. // ASCII character, we can stop here
  617. return truncated[:i+1]
  618. }
  619. if (truncated[i] & 0xC0) == 0xC0 {
  620. // Start of a multi-byte UTF-8 character
  621. return truncated[:i]
  622. }
  623. }
  624. // Fallback: return empty string if we can't find a valid UTF-8 boundary
  625. return ""
  626. }