diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index a10d035..1425555 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -59,6 +59,7 @@ android { play { serviceAccountCredentials.set(file("play-account.json")) track.set("internal") + releaseStatus.set(com.github.triplet.gradle.androidpublisher.ReleaseStatus.DRAFT) defaultToAppBundles.set(true) } diff --git a/app/assets/icon.png b/app/assets/icon.png new file mode 100644 index 0000000..ef787c2 Binary files /dev/null and b/app/assets/icon.png differ diff --git a/app/lib/services/api_service.dart b/app/lib/services/api_service.dart index dc729f0..3f2c04b 100644 --- a/app/lib/services/api_service.dart +++ b/app/lib/services/api_service.dart @@ -51,21 +51,27 @@ class ApiService { /// When connecting to a local IP the server's certificate is issued /// for the domain name, not the IP address. This client accepts /// that mismatch for the specific local host only. - http.Client _createTrustingClient(String host) { + http.Client _createTrustingClient( + String host, { + Duration? connectionTimeout, + }) { final ioClient = io.HttpClient() ..badCertificateCallback = (io.X509Certificate cert, String h, int port) => h == host; + if (connectionTimeout != null) { + ioClient.connectionTimeout = connectionTimeout; + } return IOClient(ioClient); } /// Try the local URL first; if it responds within [timeout], use it /// as the active base URL. Otherwise fall back to the main URL. Future tryLocalUrl({ - Duration timeout = const Duration(seconds: 2), + Duration timeout = const Duration(milliseconds: 500), }) async { if (_localBaseUrl == null || _localBaseUrl!.isEmpty) return; final uri = Uri.parse('$_localBaseUrl/status'); - final client = _createTrustingClient(uri.host); + final client = _createTrustingClient(uri.host, connectionTimeout: timeout); try { final response = await client.get(uri, headers: _headers) .timeout(timeout); diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 21fe82b..7154da6 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.3.0+1 +version: 1.3.0+2 environment: sdk: ^3.11.0 diff --git a/desktopmodel.cpp b/desktopmodel.cpp index d65a015..2d86d47 100644 --- a/desktopmodel.cpp +++ b/desktopmodel.cpp @@ -836,11 +836,13 @@ err_info *Desktopmodel::cancelScan (void) } -err_info *Desktopmodel::confirmScan (void) +err_info *Desktopmodel::confirmScan (QString *fname) { // qDebug () << "Desktopmodel::confirmScan"; Q_ASSERT (_scan_desk && _scan_file); + if (fname) + *fname = _scan_file->filename (); QModelIndex ind = index (_scan_file->filename (), _scan_parent); // flush the item diff --git a/desktopmodel.h b/desktopmodel.h index e58c7ea..bbc7fd1 100644 --- a/desktopmodel.h +++ b/desktopmodel.h @@ -853,8 +853,10 @@ class Desktopmodel : public QAbstractItemModel \returns error, or NULL if none */ err_info *beginScan (QModelIndex parent, const QString &stack_name); - /** confirm and save the pending scan */ - err_info *confirmScan (void); + /** confirm and save the pending scan + * + * \param fname if non-null, returns the filename of the scanned stack */ + err_info *confirmScan (QString *fname = nullptr); /** cancel and remove the pending scan */ err_info *cancelScan (void); diff --git a/desktopview.cpp b/desktopview.cpp index 6a4086a..d50ac74 100644 --- a/desktopview.cpp +++ b/desktopview.cpp @@ -75,6 +75,7 @@ Desktopview::Desktopview (QWidget *parent) Desktopview::~Desktopview () { + delete _measure; } Measure *Desktopview::getMeasure() diff --git a/desktopwidget.cpp b/desktopwidget.cpp index c8597cb..7fad64d 100644 --- a/desktopwidget.cpp +++ b/desktopwidget.cpp @@ -290,6 +290,8 @@ Desktopwidget::~Desktopwidget () delete _modelconv; delete _modelconv_assert; delete _dir; + delete _dir_proxy; + delete _model; } @@ -781,12 +783,23 @@ QModelIndex Desktopwidget::doNewDir(const QString& name, QString& path) { QModelIndex index = _dir->menuGetModelIndex (); QModelIndex src_ind = _dir_proxy->mapToSource(index); - path = _model->data(src_ind, QDirModel::FilePathRole).toString() + - QDir::separator() + name; + QString parentPath = _model->data(src_ind, + QDirModel::FilePathRole).toString(); + path = parentPath + QDir::separator() + name; Operation op("Creating directory", 0, this); QModelIndex new_ind = _model->mkdir(src_ind, name, &op); + // mkdir() refreshes the QDirModel, which can invalidate the + // proxy model's persistent indices, including _context in Dirview. + // Re-select the parent so that getRootIndex() and other operations + // that depend on _context still work. + src_ind = _model->index(parentPath); + if (src_ind.isValid()) { + QModelIndex proxy_ind = _dir_proxy->mapFromSource(src_ind); + _dir->selectContextItem(proxy_ind); + } + return new_ind; } @@ -885,7 +898,14 @@ void Desktopwidget::refreshDirmodelCache(const QString& dirPath) Operation op("Updating cache", 0, this); _model->refreshCacheFrom(src_ind, &op); +} + +void Desktopwidget::addFileToDirmodelCache(const QString &dirPath, + const QString &filename) +{ + QModelIndex src_ind = _model->index(dirPath); + _model->addFileToCache(src_ind, filename); } void Desktopwidget::slotDirChanged (QString &dirPath, QModelIndex &deskind) diff --git a/desktopwidget.h b/desktopwidget.h index 94237b7..829c05c 100644 --- a/desktopwidget.h +++ b/desktopwidget.h @@ -203,6 +203,14 @@ class Desktopwidget : public QSplitter //QWidget * @param dirPath Full path to the directory to update */ void refreshDirmodelCache(const QString& dirPath); + + /** + * @brief Add a single file to the dirmodel cache + * @param dirPath Full path to the directory containing the file + * @param filename Leaf filename to add + */ + void addFileToDirmodelCache(const QString &dirPath, + const QString &filename); protected: //bool eventFilter (QObject *watched_object, QEvent *e); diff --git a/dirmodel.cpp b/dirmodel.cpp index acb21a4..a33806a 100644 --- a/dirmodel.cpp +++ b/dirmodel.cpp @@ -159,6 +159,15 @@ bool Diritem::refreshCache(const QString dirPath, Operation *op) Q_ASSERT(_dir_cache); top = _dir_cache->findItemW(rel); + if (!top) { + // Directory is not in the cache, e.g. it was created outside the + // app since the cache was last built. Rebuild from scratch. + dropCache(); + if (!buildCache(op)) + return false; + return true; + } + TreeItem *updated = utilScanDir(dirPath, op); top->freeChildren(); @@ -172,6 +181,31 @@ bool Diritem::refreshCache(const QString dirPath, Operation *op) return true; } +bool Diritem::addFileToCache(const QString &dirPath, + const QString &filename) +{ + if (!_dir_cache && !readCache()) + return false; + + QString rel = dirPath.mid(_dir.size() + 1); + + Q_ASSERT(_dir_cache); + TreeItem *top = _dir_cache->findItemW(rel); + if (!top) + return false; + + QVector columnData = {filename}; + TreeItem *child = new TreeItem(columnData, top); + + top->appendChild(child); + if (!utilWriteTree(dirCacheFilename(), _dir_cache)) { + qInfo() << "Failed to write cache"; + return false; + } + + return true; +} + QT_WARNING_PUSH QT_WARNING_DISABLE_DEPRECATED Dirmodel::Dirmodel (QObject * parent) @@ -196,6 +230,7 @@ Dirmodel::~Dirmodel () { while (!_item.empty ()) delete _item.takeFirst (); + delete _map; } // inline bool indexValid(const QModelIndex &index) const { @@ -944,3 +979,14 @@ void Dirmodel::refreshCacheFrom(const QModelIndex& parent, Operation *op) Q_ASSERT(item); item->refreshCache(path, op); } + +void Dirmodel::addFileToCache(const QModelIndex &parent, + const QString &filename) +{ + QDir dir; + QString path = dir.absoluteFilePath(filePath(parent)); + + Diritem *item = findItem(parent); + if (item) + item->addFileToCache(path, filename); +} diff --git a/dirmodel.h b/dirmodel.h index d9c2fae..e269b5f 100644 --- a/dirmodel.h +++ b/dirmodel.h @@ -77,6 +77,14 @@ class Diritem */ bool refreshCache(const QString path, Operation *op); + /** + * @brief Add a single file to the cache + * @param dirPath Full path to the directory containing the file + * @param filename Leaf filename to add + * @return true if OK, false on failure + */ + bool addFileToCache(const QString &dirPath, const QString &filename); + private: // Get the filename for the dir cache QString dirCacheFilename() const; @@ -292,6 +300,13 @@ class Dirmodel : public QDirModel */ void refreshCacheFrom(const QModelIndex& parent, Operation *op); + /** + * @brief Add a single file to the cache + * @param parent Parent index of the directory containing the file + * @param filename Leaf filename to add + */ + void addFileToCache(const QModelIndex &parent, const QString &filename); + private: /** counts the number of files in 'path', adds it to count and returns it. Stops if count > max diff --git a/doc/install.rst b/doc/install.rst index cd36567..eabee46 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -20,6 +20,13 @@ This provides pre-built packages for the following Ubuntu releases: - Noble (24.04 LTS) - Questing (25.10) +Pre-built .deb Packages +----------------------- + +Pre-built ``.deb`` packages for a range of Debian and Ubuntu releases are +available from the `latest GitHub Release +`_. + Building from Source -------------------- diff --git a/file.cpp b/file.cpp index b766b98..36f5cbd 100644 --- a/file.cpp +++ b/file.cpp @@ -526,10 +526,12 @@ void File::colour_image_for_blank (QImage &image) for (i = 0; i < image.height (); i++) { rgb = (QRgb *)image.scanLine (i); - for (int x = 0; x < image.width (); x++) + for (int x = 0; x < image.width (); x++, rgb++) { col = *rgb; - *rgb = qRed (col) * CONFIG_preview_col_mult + qGreen (col) * CONFIG_preview_col_mult + qBlue (col); + *rgb = qRgb (qRed (col) * CONFIG_preview_col_mult, + qGreen (col) * CONFIG_preview_col_mult, + qBlue (col)); } } } diff --git a/filejpeg.cpp b/filejpeg.cpp index 76e4416..a9d1862 100644 --- a/filejpeg.cpp +++ b/filejpeg.cpp @@ -46,6 +46,7 @@ Filejpeg::Filejpeg (const QString &dir, const QString &filename, Desk *desk) Filejpeg::~Filejpeg () { + qDeleteAll (_pages); } err_info *Filejpeg::load (void) diff --git a/filemax.cpp b/filemax.cpp index 4b9ca13..5bc7310 100644 --- a/filemax.cpp +++ b/filemax.cpp @@ -734,7 +734,10 @@ err_info *Filemax::max_cache_data (cache_info &cache, int pos, { int n; - cache.buff.resize (size); + // Allocate extra padding so that callers which read a 4-byte word + // near the end of the buffer (e.g. decode_compressed_tile) do not + // overflow. Qt zero-fills the extra bytes. + cache.buff.resize (size + 4); // read the data fseek (_fin, pos, SEEK_SET); @@ -1648,7 +1651,7 @@ err_info *Filemax::decode_tiledata (chunk_info &chunk, #define DEBUG_MAX_COUNT 0 -static int decode_8bpp_preview (byte *ptr, byte *end, byte *out, byte *out_end) +int decode_8bpp_preview (byte *ptr, byte *end, byte *out, byte *out_end) { byte *out_start = out; int count, value; @@ -1674,6 +1677,64 @@ static int decode_8bpp_preview (byte *ptr, byte *end, byte *out, byte *out_end) } +/** + * Encode 8bpp greyscale preview data in the nibble format expected by + * decode_8bpp_preview(). + * + * Each output byte encodes a run of pixels: + * upper nibble = count/2 (0 means count 1) + * lower nibble = quantised grey value (inverted) + * + * The encoding is lossy: 256 grey levels are reduced to 16. + * + * @param raw Raw 8bpp preview data + * @param size Number of raw bytes + * @param out Output buffer (must be at least @a size bytes) + * @return Number of encoded bytes written + */ +int encode_8bpp_preview (byte *raw, int size, byte *out) + { + byte *raw_end = raw + size; + byte *out_start = out; + + while (raw < raw_end) + { + // Quantise pixel to nibble; the decoder inverts via 255 - value, + // bridging the polarity difference between image (0=black) and + // preview colour table (0=white), so we do not invert here. + byte nibble = *raw >> 4; + + // Count run of pixels that quantise to the same nibble + byte *run_start = raw; + + while (raw < raw_end && (*raw >> 4) == nibble) + raw++; + int count = raw - run_start; + + // Emit encoded bytes: max 30 pixels per byte (upper nibble 15) + while (count > 0) + { + if (count == 1) + { + *out++ = nibble; // upper nibble 0 means count 1 + count = 0; + } + else + { + int run = count > 30 ? 30 : count; + + // upper nibble encodes count/2, so make even + run &= ~1; + *out++ = ((run >> 1) << 4) | nibble; + count -= run; + } + } + } + + return out - out_start; + } + + /* decode a buffer of run-length-encoded data \param buf buffer holding rle data @@ -1736,7 +1797,7 @@ static err_info *rle_decode (QString &fname, byte *buf, int size, *out++ = *ptr++; *out++ = *ptr++; *out++ = *ptr++; - *out++ = 0; + *out++ = 0xff; } while ((ptr - buf) & 3) ptr++; // word align at the end of each line @@ -4294,8 +4355,7 @@ static int add_preview (chunk_info &chunk, part_info &part) case 8 : dest = (byte *)malloc (chunk.preview_bytes); - memcpy (dest, chunk.preview, chunk.preview_bytes); - len = chunk.preview_bytes; + len = encode_8bpp_preview (chunk.preview, chunk.preview_bytes, dest); break; case 24 : @@ -4693,7 +4753,7 @@ err_info *Filemax::build_chunk (chunk_info &chunk, bool force) case CT_bermuda : chunk.size = ALIGN_CHUNK (POS_chunk_header_size + 0x16 + 0xc * _pages.size ()); - //TODO: need to free old buf here + mem_free (CV &chunk.buf); CALL (alloc_chunk_buf (chunk, &buf)); add_generic_chunk_header (chunk, buf); write_bermuda (buf); @@ -4708,7 +4768,7 @@ err_info *Filemax::build_chunk (chunk_info &chunk, bool force) count += 1 + strlen (_annot_data [type].toLatin1()); chunk.size = ALIGN_CHUNK (POS_chunk_header_size + 0x45 + count); - //TODO: need to free old buf here + mem_free (CV &chunk.buf); CALL (alloc_chunk_buf (chunk, &buf)); add_generic_chunk_header (chunk, buf); write_annot (buf, chunk.size, _annot_data); @@ -4724,7 +4784,7 @@ err_info *Filemax::build_chunk (chunk_info &chunk, bool force) count += 1 + strlen (_env_data [type].toLatin1()); chunk.size = ALIGN_CHUNK (POS_chunk_header_size + POS_env_string0 + count); - //TODO: need to free old buf here + mem_free (CV &chunk.buf); CALL (alloc_chunk_buf (chunk, &buf)); add_generic_chunk_header (chunk, buf); write_env (buf, chunk.size, _env_data); @@ -4908,9 +4968,16 @@ err_info *Filemax::insert_chunk (chunk_info &new_chunk, int *posp) // we can't cope with any gaps! else { - unsigned short *ptr = (unsigned short *)new_chunk.buf; + int old_size = new_chunk.size; new_chunk.size = chunk->size; + new_chunk.buf = (byte *)realloc(new_chunk.buf, new_chunk.size); + if (new_chunk.size > old_size) + memset(new_chunk.buf + old_size, 0, + new_chunk.size - old_size); + + unsigned short *ptr = (unsigned short *)new_chunk.buf; + ptr [1] = chunk->size; ptr [2] = chunk->size >> 16; } @@ -5741,13 +5808,13 @@ err_info *Filemax::create (void) _signature = 0x46476956; _valid = true; - const char *fname = _pathname.toLatin1().constData(); - _fin = fopen (fname, "w+b"); + QByteArray fname = _pathname.toLatin1(); + _fin = fopen (fname.constData(), "w+b"); if (!_fin) return err_make (ERRFN, ERR_cannot_open_file1, - _pathname.toLatin1 ().constData()); + fname.constData()); - utilSetGroup(fname); + utilSetGroup(fname.constData()); debug2 (("page count %d, chunk count %d\n", _pages.size (), _chunks.size ())); return NULL; @@ -5770,131 +5837,137 @@ Filemaxpage::~Filemaxpage (void) } -err_info *Filemax::rebuildPreviews () +err_info *Filemax::rebuildPagePreview (int pagenum) { CALL (ensure_open ()); CALL (ensure_all_chunks ()); - int pc = pagecount (); + chunk_info *chunk; + page_info *page; - for (int pagenum = 0; pagenum < pc; pagenum++) - { - chunk_info *chunk; - page_info *page; + CALL (find_page_chunk (pagenum, chunk, NULL, &page)); - CALL (find_page_chunk (pagenum, chunk, NULL, &page)); + // only fix 8bpp greyscale pages + if (chunk->bits != 8) + return NULL; - // only fix 8bpp greyscale pages - if (chunk->bits != 8) - continue; + // skip pages that already have a real preview (> 4 bytes) + if (chunk->parts.size () <= PT_preview) + return NULL; + if (chunk->parts [PT_preview].size > 4) + return NULL; - // skip pages that already have a real preview (> 4 bytes) - if (chunk->parts.size () <= PT_preview) - continue; - if (chunk->parts [PT_preview].size > 4) - continue; + // load the chunk buffer from disk so we can extract part data + CALL (read_chunk_buf (*chunk)); - // load the chunk buffer from disk so we can extract part data - CALL (read_chunk_buf (*chunk)); + // copy each part's data from chunk.buf into individual part.buf + for (int i = 0; i < chunk->parts.size (); i++) + { + part_info &part = chunk->parts [i]; - // copy each part's data from chunk.buf into individual part.buf - for (int i = 0; i < chunk->parts.size (); i++) + if (!part.buf && part.size > 0) { - part_info &part = chunk->parts [i]; - - if (!part.buf && part.size > 0) - { - part.buf = (byte *)malloc (part.size); - memcpy (part.buf, chunk->buf + 0x20 + part.start, part.size); - } + part.buf = (byte *)malloc (part.size); + memcpy (part.buf, chunk->buf + 0x20 + part.start, part.size); } + } - // work on a local copy so insert_chunk can safely reallocate _chunks - chunk_info local = *chunk; + // work on a local copy so insert_chunk can safely reallocate _chunks + chunk_info local = *chunk; - local.buf = NULL; // don't share the original buffer pointer + local.buf = NULL; // don't share the original buffer pointer - // transfer tile ownership to local so chunk_free won't double-free - chunk->tile = NULL; - chunk->tile_count = 0; + // transfer tile ownership to local so chunk_free won't double-free + chunk->tile = NULL; + chunk->tile_count = 0; - // decode the full image from tile data - byte *imagep = NULL; + // decode the full image from tile data + byte *imagep = NULL; - CALL (decode_image (local, imagep, NULL)); + CALL (decode_image (local, imagep, NULL)); - // set up the preview size and build the preview - local.preview_size.x = local.image_size.x / PREVIEW_SCALE; - local.preview_size.y = local.image_size.y / PREVIEW_SCALE; - build_preview (local, local.line_bytes, local.bits); + // set up the preview size and build the preview + local.preview_size.x = local.image_size.x / PREVIEW_SCALE; + local.preview_size.y = local.image_size.y / PREVIEW_SCALE; + build_preview (local, local.line_bytes, local.bits); - // free the old preview part buffer and create the new one - part_info &pv = local.parts [PT_preview]; + // free the old preview part buffer and create the new one + part_info &pv = local.parts [PT_preview]; - if (pv.buf) - free (pv.buf); - pv.buf = NULL; - pv.size = 0; - add_preview (local, pv); + if (pv.buf) + free (pv.buf); + pv.buf = NULL; + pv.size = 0; + add_preview (local, pv); - // recalculate part offsets - int total = POS_part0 + 8 * local.parts.size (); + // recalculate part offsets + int total = POS_part0 + 8 * local.parts.size (); - for (int i = 0; i < local.parts.size (); i++) - { - part_info &part = local.parts [i]; + for (int i = 0; i < local.parts.size (); i++) + { + part_info &part = local.parts [i]; - part.start = total - 0x20; - total += part.size; - } - local.size = ALIGN_CHUNK (total); + part.start = total - 0x20; + total += part.size; + } + local.size = ALIGN_CHUNK (total); - // reassemble the chunk buffer - byte *buf; + // reassemble the chunk buffer + byte *buf; - // assign a new chunkid so find_page_chunk won't match the old - // unused chunk on the next load - local.chunkid = _chunkid_next++; - page->chunkid = local.chunkid; + // assign a new chunkid so find_page_chunk won't match the old + // unused chunk on the next load + local.chunkid = _chunkid_next++; + page->chunkid = local.chunkid; - CALL (alloc_chunk_buf (local, &buf)); - add_image_header (local, buf); - for (int i = 0; i < local.parts.size (); i++) - { - part_info &part = local.parts [i]; + CALL (alloc_chunk_buf (local, &buf)); + add_image_header (local, buf); + for (int i = 0; i < local.parts.size (); i++) + { + part_info &part = local.parts [i]; - memcpy (buf + 0x20 + part.start, part.buf, part.size); - } - CALL (insert_chunk (local, &page->image)); + memcpy (buf + 0x20 + part.start, part.buf, part.size); + } + CALL (insert_chunk (local, &page->image)); - // clear local ownership: insert_chunk copied everything into _chunks - local.image = NULL; - local.preview = NULL; - local.tile = NULL; - local.tile_count = 0; - local.buf = NULL; - local.parts.clear (); + // clear local ownership: insert_chunk copied everything into _chunks + local.image = NULL; + local.preview = NULL; + local.tile = NULL; + local.tile_count = 0; + local.buf = NULL; + local.parts.clear (); - // update the roswell chunk to point to the new image position - chunk_info *ros_chunk; + // update the roswell chunk to point to the new image position + chunk_info *ros_chunk; - CALL (chunk_find (page->roswell, ros_chunk, NULL)); - CALL (read_chunk_buf (*ros_chunk)); - unsigned short *rdata = (unsigned short *)(ros_chunk->buf + 0x42); + CALL (chunk_find (page->roswell, ros_chunk, NULL)); + CALL (read_chunk_buf (*ros_chunk)); + unsigned short *rdata = (unsigned short *)(ros_chunk->buf + 0x42); - rdata [0] = page->image & 0xffff; - rdata [1] = page->image >> 16; - ros_chunk->saved = false; + rdata [0] = page->image & 0xffff; + rdata [1] = page->image >> 16; + ros_chunk->saved = false; - printf ("Page %d: rebuilt preview (%dx%d)\n", pagenum + 1, - local.preview_size.x, local.preview_size.y); - } + printf ("Page %d: rebuilt preview (%dx%d)\n", pagenum + 1, + local.preview_size.x, local.preview_size.y); CALL (flush ()); return NULL; } +err_info *Filemax::rebuildPreviews () + { + int pc = pagecount (); + + for (int pagenum = 0; pagenum < pc; pagenum++) + CALL (rebuildPagePreview (pagenum)); + + return NULL; + } + + err_info *Filemax::getImage (int pagenum, bool, QImage &image, QSize &Size, QSize &trueSize, int &bpp, bool blank) { @@ -5966,6 +6039,23 @@ err_info *Filemax::getPreviewPixmap (int pagenum, QPixmap &pixmap, bool blank) if (debug_level >= 3) show_file (stderr); CALL (find_page_chunk (pagenum, chunk, &temp, NULL)); + + // Rebuild stub greyscale previews on the fly + if (chunk->bits == 8 + && chunk->parts.size () > PT_preview + && chunk->parts [PT_preview].size <= 4) + { + if (temp) + { + chunk_free (*chunk); + delete chunk; + } + ensure_closed (); + CALL (rebuildPagePreview (pagenum)); + CALL (ensure_open ()); + CALL (find_page_chunk (pagenum, chunk, &temp, NULL)); + } + QSize Size = QSize (chunk->preview_size.x, chunk->preview_size.y); int bpp = chunk->bits == 24 ? 24 : 8; CALL(ensure_open()); diff --git a/filemax.h b/filemax.h index 89cc994..4c1d68b 100644 --- a/filemax.h +++ b/filemax.h @@ -220,6 +220,9 @@ class Filemax : public File /** rebuild missing greyscale previews in the file */ err_info *rebuildPreviews (); + /** rebuild a stub greyscale preview for a single page, if needed */ + err_info *rebuildPagePreview (int pagenum); + /*********** end of functions which the base class should implement ******/ public: @@ -634,6 +637,11 @@ class Filemax : public File }; +// 8bpp preview encode/decode (nibble-based format) +int decode_8bpp_preview (byte *ptr, byte *end, byte *out, byte *out_end); +int encode_8bpp_preview (byte *raw, int size, byte *out); + + class Filemaxpage : public Filepage { public: diff --git a/mainwidget.cpp b/mainwidget.cpp index 6d4b673..29f66da 100644 --- a/mainwidget.cpp +++ b/mainwidget.cpp @@ -501,13 +501,14 @@ void Mainwidget::slotStackConfirm (void) // qDebug () << "slotStackConfirm"; if (!_scan_cancelling) { - err_info *err = _contents->confirmScan (); + QString fname; + err_info *err = _contents->confirmScan (&fname); //FIXME: should we cancel in this case? if (err) _scan->cancelScan (err); - _desktop->refreshDirmodelCache(_scan_path); + _desktop->addFileToDirmodelCache(_scan_path, fname); } } diff --git a/mainwindow.cpp b/mainwindow.cpp index c5a07f9..d5b8e90 100644 --- a/mainwindow.cpp +++ b/mainwindow.cpp @@ -330,6 +330,7 @@ void Mainwindow::updateProgress(enum Operation::state_t state, int percent, delete _progress; delete _label; _progress = 0; + _label = 0; break; default: _progress->setValue (percent); diff --git a/paperstack.cpp b/paperstack.cpp index f24c4c5..4190bb4 100644 --- a/paperstack.cpp +++ b/paperstack.cpp @@ -472,7 +472,7 @@ void PPage::finishJpeg (void) jpeg_destroy_decompress (&_cinfo); if (_jerr.err) qDebug () << "JPEG error" << _jerr.err; - delete _buffer; + delete[] _buffer; _jpeg_created = false; } } diff --git a/site/index.html b/site/index.html index 7de57ed..43e2073 100644 --- a/site/index.html +++ b/site/index.html @@ -140,7 +140,8 @@

Paperman

diff --git a/test/test_ops.cpp b/test/test_ops.cpp index 2b5e8e1..9e4b0ab 100644 --- a/test/test_ops.cpp +++ b/test/test_ops.cpp @@ -9,6 +9,7 @@ #include "desktopview.h" #include "desktopwidget.h" #include "dirmodel.h" +#include "dirview.h" #include "mainwindow.h" #include "test_ops.h" @@ -553,6 +554,42 @@ void TestOps::testCreateDir() QCOMPARE(foundIndex.isValid(), true); } +void TestOps::testCreateDirInNewParent() +{ + Mainwindow me; + + // Add our test repo + auto path = setupRepo(); + Desktopwidget *desktop = me.getDesktop(); + err_info *err = desktop->addDir(path); + Q_ASSERT(!err); + + // Create a top-level subdirectory to trigger initial cache building + QString topDir = path + "/subdir"; + QModelIndex dirIndex; + bool ok = desktop->newDir(topDir, dirIndex); + QCOMPARE(ok, true); + + // Create a new directory on the filesystem, bypassing paperman. This + // simulates a directory that was created outside the app, or one that + // appeared since the cache was last built (e.g. a new year directory) + QDir(path + "/main/one").mkdir("c"); + QDir dir(path + "/main/one/c"); + QCOMPARE(dir.exists(), true); + + // Now try to create a subdirectory inside the new directory. The cache + // does not know about "main/one/c", so refreshCache() must handle a + // missing parent gracefully rather than crashing. + QString nestedDir = path + "/main/one/c/leaf"; + QModelIndex nestedIndex; + ok = desktop->newDir(nestedDir, nestedIndex); + QCOMPARE(ok, true); + + // Check that the directory was created on disk + QDir leafDir(nestedDir); + QCOMPARE(leafDir.exists(), true); +} + void TestOps::testMoveToDir() { Mainwindow me; @@ -869,3 +906,159 @@ void TestOps::testRenameDir() QModelIndex oldDirIndex = dirmodel->index(oldPath); QCOMPARE(oldDirIndex.isValid(), false); } + +void TestOps::testFindFoldersSuggestsMonth() +{ + Mainwindow me; + + // Set up a repo with bills/2026 but no month directories yet + auto path = setupRepo(); + QDir dir(path); + Q_ASSERT(dir.mkpath("bills/2026")); + + Desktopwidget *desktop = me.getDesktop(); + err_info *err = desktop->addDir(path); + Q_ASSERT(!err); + + Dirmodel *dirmodel = desktop->getDirmodel(); + Q_ASSERT(dirmodel); + + QModelIndex root = dirmodel->index(path); + QCOMPARE(root.isValid(), true); + + // Trigger initial cache building by calling findFolders + QStringList missing; + QStringList folders = dirmodel->findFolders("bills", path, root, missing, + nullptr); + QCOMPARE(missing.size(), 0); + + // Now create month directories through the app, as the user did + QString jan = path + "/bills/2026/01jan"; + QString feb = path + "/bills/2026/02feb"; + QModelIndex janIndex, febIndex; + bool ok = desktop->newDir(jan, janIndex); + QCOMPARE(ok, true); + ok = desktop->newDir(feb, febIndex); + QCOMPARE(ok, true); + + // Re-obtain the root index since newDir modifies the model + root = dirmodel->index(path); + QCOMPARE(root.isValid(), true); + + // Search again - the in-memory cache should now include the new + // directories and suggest creating the current month + folders = dirmodel->findFolders("bills", path, root, missing, nullptr); + + QVERIFY2(missing.contains("bills/2026/03mar"), + qPrintable(QString("Expected 'bills/2026/03mar' in missing list, " + "got: [%1]").arg(missing.join(", ")))); +} + +void TestOps::testFindFoldersSuggestsMonthViaDesktop() +{ + Mainwindow me; + + // Set up a repo with bills/2026 but no month directories yet + auto path = setupRepo(); + QDir dir(path); + Q_ASSERT(dir.mkpath("bills/2026")); + + Desktopwidget *desktop = me.getDesktop(); + err_info *err = desktop->addDir(path); + Q_ASSERT(!err); + + Dirmodel *dirmodel = desktop->getDirmodel(); + Q_ASSERT(dirmodel); + + // Trigger initial cache building through the desktop path, which + // uses getRootDirectory() / getRootIndex() internally + QStringList missing; + QString dirPath; + QStringList folders = desktop->findFolders("bills", dirPath, missing); + QCOMPARE(dirPath, path); + QCOMPARE(missing.size(), 0); + + // Simulate the UI workflow: user navigates to bills/2026 in the + // Dirview tree, then right-clicks to create directories. The UI + // doNewDir() creates via _model->mkdir() then re-selects the parent. + // First, set the Dirview context to bills/2026, as if the user + // right-clicked on it. + QModelIndex bills2026_src = dirmodel->index(path + "/bills/2026"); + QVERIFY2(bills2026_src.isValid(), + "bills/2026 should exist in the model"); + QModelIndex bills2026_proxy = desktop->_dir_proxy->mapFromSource( + bills2026_src); + desktop->_dir->selectContextItem(bills2026_proxy); + + // Create 01jan via doNewDir(), as the UI does from the right-click menu + QString newPath; + QModelIndex jan_ind = desktop->doNewDir("01jan", newPath); + QVERIFY2(jan_ind.isValid(), "doNewDir 01jan should succeed"); + + // Create 02feb the same way - doNewDir uses _context as the parent + QModelIndex feb_ind = desktop->doNewDir("02feb", newPath); + QVERIFY2(feb_ind.isValid(), "doNewDir 02feb should succeed"); + + // Check that getRootIndex() still works after doNewDir + QModelIndex root = desktop->getRootIndex(); + QVERIFY2(root.isValid(), + "getRootIndex() should still be valid after doNewDir"); + QVERIFY2(dirmodel->isRoot(root), + "getRootIndex() should return a root index"); + + // Search through the desktop path - this is how the pscan dialog + // finds folders. + folders = desktop->findFolders("bills", dirPath, missing); + + QVERIFY2(missing.contains("bills/2026/03mar"), + qPrintable(QString("Expected 'bills/2026/03mar' in missing list, " + "got: [%1]").arg(missing.join(", ")))); +} + +void TestOps::testFindFoldersSuggestsMonthAfterRefresh() +{ + Mainwindow me; + + // Set up a repo with bills/2026 but no month directories yet + auto path = setupRepo(); + QDir dir(path); + Q_ASSERT(dir.mkpath("bills/2026")); + + Desktopwidget *desktop = me.getDesktop(); + err_info *err = desktop->addDir(path); + Q_ASSERT(!err); + + Dirmodel *dirmodel = desktop->getDirmodel(); + Q_ASSERT(dirmodel); + + // Trigger initial cache building + QStringList missing; + QString dirPath; + QStringList folders = desktop->findFolders("bills", dirPath, missing); + QCOMPARE(dirPath, path); + QCOMPARE(missing.size(), 0); + + // Set the Dirview context to bills/2026, as if the user right-clicked + QModelIndex bills2026_src = dirmodel->index(path + "/bills/2026"); + QVERIFY(bills2026_src.isValid()); + QModelIndex bills2026_proxy = desktop->_dir_proxy->mapFromSource( + bills2026_src); + desktop->_dir->selectContextItem(bills2026_proxy); + + // Create directories via doNewDir, as the UI does + QString newPath; + desktop->doNewDir("01jan", newPath); + desktop->doNewDir("02feb", newPath); + + // User presses "Refresh cache" from the context menu, which also + // uses _context to determine the refresh point + desktop->refreshDirmodelCache(path); + + // Now search via desktop path + folders = desktop->findFolders("bills", dirPath, missing); + + QVERIFY2(missing.contains("bills/2026/03mar"), + qPrintable(QString("Expected 'bills/2026/03mar' in missing list " + "after cache refresh, got: [%1]") + .arg(missing.join(", ")))); +} diff --git a/test/test_ops.h b/test/test_ops.h index 78c1375..70b2db2 100644 --- a/test/test_ops.h +++ b/test/test_ops.h @@ -52,6 +52,9 @@ private slots: //! Test creating a dir void testCreateDir(); + //! Test creating a dir inside a directory not in the cache + void testCreateDirInNewParent(); + //! Test moving a file to a different directory void testMoveToDir(); @@ -64,6 +67,15 @@ private slots: //! Test renaming a directory void testRenameDir(); + //! Test that findFolders suggests the next month directory + void testFindFoldersSuggestsMonth(); + + //! Test that findFolders via the desktop path suggests the next month + void testFindFoldersSuggestsMonthViaDesktop(); + + //! Test that findFolders works after a cache refresh + void testFindFoldersSuggestsMonthAfterRefresh(); + private: //! Setup the test repo and return its model and root index void getTestRepo(Mainwindow *me, Desktopmodel*& model, diff --git a/test/test_utils.cpp b/test/test_utils.cpp index a37fd37..1749783 100644 --- a/test/test_utils.cpp +++ b/test/test_utils.cpp @@ -1,5 +1,6 @@ #include +#include "../filemax.h" #include "../utils.h" #include "test.h" @@ -328,3 +329,116 @@ void TestUtils::testFindItem() chkw = root->findItemW("somedir/more-subdir/another-file"); QCOMPARE(chk, chkw); } + +void TestUtils::testPreview8bppRoundtrip() +{ + // Create a test pattern with varying grey levels (image convention: + // 0=black, 255=white) + const int width = 100; + const int height = 50; + const int size = width * height; + byte raw[size]; + + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) + raw[y * width + x] = (x * 255) / (width - 1); + + // Encode + byte encoded[size]; + int enc_len = encode_8bpp_preview(raw, size, encoded); + + QVERIFY(enc_len > 0); + QVERIFY(enc_len <= size); + + // Decode: the decoder inverts the polarity (image 0=black becomes + // preview 255=black) so the expected value is 255 - raw + byte decoded[size]; + + memset(decoded, 0, size); + int dec_len = decode_8bpp_preview(encoded, encoded + enc_len, + decoded, decoded + size); + QCOMPARE(dec_len, size); + + // Check each pixel against the expected inverted value, within + // quantisation tolerance (nibble reduces 256 levels to 16) + int max_err = 0; + + for (int i = 0; i < size; i++) { + int expected = 255 - raw[i]; + int diff = abs(expected - (int)decoded[i]); + + if (diff > max_err) + max_err = diff; + } + + // Nibble quantisation can produce up to ~17 error per pixel + QVERIFY2(max_err <= 17, + qPrintable(QString("max pixel error %1 exceeds tolerance 17") + .arg(max_err))); + + // Verify the decoded data is not all one value + int sum = 0; + + for (int i = 0; i < size; i++) + sum += decoded[i]; + QVERIFY2(sum > 0, "decoded preview is all-zero"); + QVERIFY2(sum < size * 255, "decoded preview is all-255"); +} + +void TestUtils::testPreviewFromJpeg() +{ + // Load the deterministic greyscale test image + QImage img(testSrc + "/greyscale_gradient.jpg"); + + QVERIFY2(!img.isNull(), "failed to load greyscale_gradient.jpg"); + + // Convert to 8bpp greyscale if needed + if (img.format() != QImage::Format_Indexed8) + img = img.convertToFormat(QImage::Format_Indexed8); + + int pw = img.width() / 24; + int ph = img.height() / 24; + int pwidth = (pw + 3) & ~3; // word-aligned + int psize = pwidth * ph; + + // Scale down to preview size (matching scale_8bpp logic) + QByteArray preview(psize, 0); + + for (int y = 0; y < ph; y++) { + for (int x = 0; x < pw; x++) { + int sum = 0; + int count = 0; + + for (int sy = 0; sy < 24 && y * 24 + sy < img.height(); sy++) { + const uchar *line = img.scanLine(y * 24 + sy); + + for (int sx = 0; sx < 24; sx++) + sum += line[x * 24 + sx]; + count += 24; + } + preview[y * pwidth + x] = sum / count; + } + } + + // Encode + QByteArray encoded(psize, 0); + int enc_len = encode_8bpp_preview((byte *)preview.data(), psize, + (byte *)encoded.data()); + QVERIFY(enc_len > 0); + + // Decode + byte *enc = (byte *)encoded.data(); + QByteArray decoded(psize, 0); + + decode_8bpp_preview(enc, enc + enc_len, + (byte *)decoded.data(), + (byte *)decoded.data() + psize); + + // Compress and check deterministic size + QByteArray compressed = qCompress(decoded); + int csize = compressed.size(); + + QVERIFY2(csize > 50, qPrintable( + QString("compressed preview too small: %1").arg(csize))); + QCOMPARE(csize, 1412); +} diff --git a/test/test_utils.h b/test/test_utils.h index 05e787b..bc2395a 100644 --- a/test/test_utils.h +++ b/test/test_utils.h @@ -23,6 +23,12 @@ private slots: void testAdopt(); void testFindItem(); void testImageDepth(); + + //! Test 8bpp preview encode/decode roundtrip + void testPreview8bppRoundtrip(); + + //! Test preview encode/decode against the greyscale test image + void testPreviewFromJpeg(); private: // Create files in a temporary directory structure used for testing void createDirStructure(QTemporaryDir& tmp);