// Importing a folder of screenshots, photos and videos into the library: every image under it (PNG, // JPEG, HEIC, AVIF, WebP, GIF, BMP, TIFF, camera RAW), every video (MP4, MOV, MKV, WebM, AVI, WMV, MPEG; // on Windows, with FFmpeg) and every Snagit .snagx becomes an .mcap in a folder of its own in the // library, one folder for each place imported from (D:\McapIndex\mcap\SnagitLibraryWork, ...). The // files imported from are only read, never changed. // // An image's .mcap holds the file's bytes exactly as they were (its original), a preview turned the // right way up, and Capture Text with metadata.json (CaptureDate, WindowName as the file's name, // AppName as the camera or phone) and import.json (where it came from, its EXIF). A video's is the // same, with a frame from it, turned the right way up, as its rendered image (what OCR and labels // read), and what its container says in import.json. A video is copied into its .mcap a few MB at a // time, never read whole, so it can be any size. A .snagx becomes the same .mcap SnagitConverter // makes of it (modules/Snagx). // // Each image is stored once in the whole library: the library index (IMPORT_INDEX_NAME, in the library // folder) holds a hash of every .mcap's original image, made once for the files already there and kept // up to date after that; an image whose hash is in it is reported as a duplicate, with where it is. // // The import runs on a thread of its own, which lists the files, brings the index up to date, and then // imports with IMPORT_WORKERS threads. New captures go into the grid as they are written; when it ends, // the OCR scan reads and labels them, if asked to. IMPORT_WINDOW :: "Import"; IMPORT_INDEX_NAME :: ".import-index"; // in the library folder: path, size, time and hash of each .mcap IMPORT_SOURCES_NAME :: ".import-sources"; // in the library folder: the folder each library folder was last imported from IMPORT_MAX_FILE :: 1024 * 1024 * 1024; // larger images are not read (videos are streamed, any size) IMPORT_NOTES_MAX :: 5000; // failures and duplicates listed; the rest are only counted IMPORT_SAVE_EVERY :: 250; // captures imported between saves of the index IMPORT_HEAD_SIZE :: 256; // the first bytes looked at, to tell what a file is // Videos, by extension, for those whose first bytes do not say so (MPEG transport streams). IMPORT_VIDEO_EXTENSIONS :: string.["mp4", "mov", "m4v", "avi", "mkv", "webm", "3gp", "3g2", "wmv", "mts", "m2ts", "ts", "mpg", "mpeg", "flv"]; // Not images or videos, by extension: skipped without being read. Anything else is read and looked // at. thm and lrv are a GoPro's thumbnails and low-resolution copies of its videos. IMPORT_OTHER_EXTENSIONS :: string.["aae", "xmp", "json", "txt", "ini", "db", "pdf", "zip", "lnk", "url", "html", "htm", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "mp3", "m4a", "wav", "aac", "exe", "dll", "mcap", "tmp", "thm", "lrv", "mpo"]; Import_Phase :: enum u8 { IDLE; LISTING; INDEXING; IMPORTING; DONE; } Import_Outcome :: enum u8 { IMPORTED; DUPLICATE; SKIPPED; FAILED; } // A file that failed, or was a duplicate, and why or of what. Heap. Import_Note :: struct { path: string; text: string; } Index_Record :: struct { size: s64; modified: s64; // nanoseconds hash: u64; } importer: struct { window_open: bool; source_buffer: [1024] u8; dest_buffer: [256] u8; dest_edited: bool; // typed in, so a new source does not replace it read_after := true; // read and label what was imported, with the OCR scan sources: Table(string, string); // library folder name -> the folder last imported into it; main thread // Set by the main thread before the thread starts; read-only while it runs. source: string; dest_name: string; dest_folder: string; library: string; thread: Thread; thread_started: bool; running: bool; // set by the main thread, cleared by the import thread when it ends phase: Import_Phase; paused, cancel: bool; start: Apollo_Time; paused_seconds: float64; pause_start: Apollo_Time; elapsed_seconds: float64; // once it has ended files: [..] string; // what LISTING found; the import thread's listed: s64; index_total, index_done: s64; next: s64; // the next file a worker takes done, imported, duplicates, skipped, failed: s64; error: string; // why the import could not run at all // Under mutex. mutex: Mutex; failures: [..] Import_Note; duplicate_notes: [..] Import_Note; skipped_kinds: Table(string, s64); // "mp4", "video", "unknown" ... -> count new_paths: [..] string; // written, for the main thread to put in the grid by_hash: Table(u64, string); // every original in the library -> its path, relative to the library records: Table(string, Index_Record); reserved_names: Table(string, bool); // output paths being written since_save: s64; // Main thread. new_indices: [..] s32; // the captures this import added, for the scan after it scan_pending: bool; segment_pending: bool; // the folder imported into is set to segment: its new images, after the scan flag_started: bool; // -import has started its import } // ---------------------------------------------------------------- startup and shutdown import_startup :: () { init(*importer.mutex, "import"); importer.files.allocator = context.default_allocator; importer.failures.allocator = context.default_allocator; importer.duplicate_notes.allocator = context.default_allocator; importer.new_paths.allocator = context.default_allocator; importer.new_indices.allocator = context.default_allocator; import_sources_load(); } import_shutdown :: () { atomic_swap(*importer.cancel, true); atomic_swap(*importer.paused, false); if importer.thread_started thread_is_done(*importer.thread, -1); } // ---------------------------------------------------------------- starting // Starts importing source into the library folder dest_name; false, with a toast, when it cannot. import_start :: (source: string, dest_name: string) -> bool { if importer.running return false; source_path := trim(source); while source_path.count > 3 && (source_path[source_path.count - 1] == #char "/" || source_path[source_path.count - 1] == #char "\\") source_path.count -= 1; dest := trim(dest_name); if !source_path.count || !dest.count { toast_show(.Error, "Choose a folder to import and a library folder to import it into"); return false; } if !import_name_is_valid(dest) { toast_show(.Error, "\"%\" cannot be a folder's name", dest); return false; } if !file_exists(source_path) { toast_show(.Error, "% does not exist", source_path); return false; } push_allocator(context.default_allocator); free(importer.source); free(importer.dest_name); free(importer.dest_folder); free(importer.library); free(importer.error); importer.source = copy_string(source_path); path_overwrite_separators(importer.source, #char "/"); importer.dest_name = copy_string(dest); importer.library = copy_string(viewer_folder()); importer.dest_folder = sprint("%/%", importer.library, dest); importer.error = ""; // The library is never imported into itself. lower_source := to_lower_copy(importer.source,, temp); lower_library := to_lower_copy(importer.library,, temp); path_overwrite_separators(lower_library, #char "/"); if lower_source == lower_library || begins_with(lower_source, tprint("%/", lower_library)) || begins_with(lower_library, tprint("%/", lower_source)) { toast_show(.Error, "The folder to import holds the library, or is inside it"); return false; } for importer.files free(it); array_reset(*importer.files); lock(*importer.mutex); for importer.failures { free(it.path); free(it.text); } for importer.duplicate_notes { free(it.path); free(it.text); } array_reset(*importer.failures); array_reset(*importer.duplicate_notes); for _, key: importer.skipped_kinds free(key); table_reset(*importer.skipped_kinds); unlock(*importer.mutex); array_reset(*importer.new_indices); importer.listed = 0; importer.index_total = 0; importer.index_done = 0; importer.next = 0; importer.done = 0; importer.imported = 0; importer.duplicates = 0; importer.skipped = 0; importer.failed = 0; importer.paused = false; importer.cancel = false; importer.paused_seconds = 0; importer.elapsed_seconds = 0; importer.phase = .LISTING; importer.start = current_time_monotonic(); importer.scan_pending = false; table_set(*importer.sources, copy_string(dest), copy_string(importer.source)); import_sources_save(); importer.running = true; if importer.thread_started thread_deinit(*importer.thread); importer.thread_started = thread_init(*importer.thread, import_thread_proc, temporary_storage_size = 4 * 1024 * 1024, name = "import"); if !importer.thread_started { importer.running = false; importer.phase = .IDLE; toast_show(.Error, "Could not start the import"); return false; } thread_start(*importer.thread); log("[Import] Importing % into %.", importer.source, importer.dest_folder); return true; } import_name_is_valid :: (name: string) -> bool { if !name.count || name == "." || name == ".." || name[0] == #char "." return false; for name if it < 32 || it == #char "/" || it == #char "\\" || it == #char ":" || it == #char "*" || it == #char "?" || it == #char "\"" || it == #char "<" || it == #char ">" || it == #char "|" return false; return name[name.count - 1] != #char " " && name[name.count - 1] != #char "."; } // ---------------------------------------------------------------- the import thread import_thread_proc :: (thread: *Thread) -> s64 { context.allocator = context.default_allocator; defer { importer.elapsed_seconds = import_elapsed(); atomic_swap(*importer.phase, Import_Phase.DONE); atomic_swap(*importer.running, false); } // The files to import, sorted so a second run goes through them in the same order. Found :: struct { files: *[..] string; } found := Found.{*importer.files}; visited := visit_files(importer.source, true, *found, (info: *File_Visit_Info, found: *Found) { if info.is_directory { name := info.short_name; if name.count && (name[0] == #char "." || name[0] == #char "$" || name == "System Volume Information") info.descend_into_directory = false; return; } array_add(found.files, copy_string(info.full_name)); atomic_add(*importer.listed, 1); }, visit_files = true, visit_directories = true, follow_directory_symlinks = false); if !visited || importer.cancel { if !visited importer.error = sprint("% cannot be read", importer.source); return 0; } quick_sort(importer.files, compare); if !make_directory_if_it_does_not_exist(importer.dest_folder, recursive = true) { importer.error = sprint("% cannot be made", importer.dest_folder); return 0; } atomic_swap(*importer.phase, Import_Phase.INDEXING); if !import_index_update() || importer.cancel return 0; atomic_swap(*importer.phase, Import_Phase.IMPORTING); import_run_workers(import_worker); import_index_save(); log("[Import] % into %: % imported, % duplicates, % skipped, % failed, of % files, in %%.", importer.source, importer.dest_name, importer.imported, importer.duplicates, importer.skipped, importer.failed, importer.files.count, ocr_duration_text(import_elapsed()), ifx importer.cancel then " (cancelled)" else ""); return 0; } IMPORT_WORKERS :: 0; // 0: worked out from the processors // Runs proc on several threads until it returns false on each, then waits for them. import_run_workers :: (proc: (thread: *Thread) -> s64) { count := clamp(get_number_of_processors(.ALL_LOGICAL) / 2, 2, 8); if IMPORT_WORKERS count = IMPORT_WORKERS; threads := NewArray(count, Thread,, context.default_allocator); defer array_free(threads,, context.default_allocator); started := 0; for * threads { // Reading an image and its .mcap takes about three times its size; temporary storage grows past this as needed. if !thread_init(it, proc, temporary_storage_size = 16 * 1024 * 1024, name = tprint("import %", it_index)) break; thread_start(it); started += 1; } if !started proc(null); // on this thread, then for 0..started - 1 { thread_is_done(*threads[it], -1); thread_deinit(*threads[it]); } } import_worker :: (thread: *Thread) -> s64 { context.allocator = context.default_allocator; while true { while importer.paused && !importer.cancel sleep_milliseconds(100); if importer.cancel break; n := atomic_add(*importer.next, 1); if n >= importer.files.count break; path := importer.files[n]; outcome, text, new_path := import_file(path); lock(*importer.mutex); if outcome == { case .IMPORTED; importer.imported += 1; array_add(*importer.new_paths, copy_string(new_path)); importer.since_save += 1; case .DUPLICATE; importer.duplicates += 1; if importer.duplicate_notes.count < IMPORT_NOTES_MAX array_add(*importer.duplicate_notes, .{copy_string(path), copy_string(text)}); case .SKIPPED; importer.skipped += 1; found, count := table_find(*importer.skipped_kinds, text); if found table_set(*importer.skipped_kinds, text, count + 1); else table_set(*importer.skipped_kinds, copy_string(text), 1); case .FAILED; importer.failed += 1; if importer.failures.count < IMPORT_NOTES_MAX array_add(*importer.failures, .{copy_string(path), copy_string(text)}); } save := importer.since_save >= IMPORT_SAVE_EVERY; if save importer.since_save = 0; unlock(*importer.mutex); if outcome == .FAILED log_error("[Import] % %", path, text); atomic_add(*importer.done, 1); if save import_index_save(); reset_temporary_storage(); } return 0; } // Imports one file. The text says why it failed or was skipped (what kind of file: "aae", "empty"), // or, for a duplicate, where it already is; new_path is where an import went. Temp. import_file :: (path: string) -> Import_Outcome, text: string, new_path: string { name := path_filename(path); extension := to_lower_copy(path_extension(name),, temp); if array_find(IMPORT_OTHER_EXTENSIONS, extension) return .SKIPPED, extension, ""; modified, size, stat_ok := file_modtime_and_size(path); if !stat_ok return .FAILED, "cannot be read", ""; if size == 0 return .SKIPPED, "empty", ""; // A look at the first bytes, before reading what may not be an image at all. head := import_read_head(path, IMPORT_HEAD_SIZE); kind := image_kind_from_name(image_kind(head), name); is_snagx := extension == "snagx" && head.count >= 4 && memcmp(head.data, "PK\x03\x04".data, 4) == 0; if !is_snagx { if kind == .VIDEO || (kind == .UNKNOWN && array_find(IMPORT_VIDEO_EXTENSIONS, extension)) { outcome, text, new_path := import_video_file(path, name, head, size, modified); return outcome, text, new_path; } if kind == .UNKNOWN return .SKIPPED, ifx extension then extension else "no extension", ""; } if size > IMPORT_MAX_FILE return .FAILED, tprint("is % MB, larger than the % MB an import reads", size / (1024 * 1024), IMPORT_MAX_FILE / (1024 * 1024)), ""; data, read_ok := read_entire_file(path, log_errors = false,, temp); if !read_ok return .FAILED, "cannot be read", ""; bytes := cast([] u8) data; m: Mcap; out_bytes: [] u8; hash: u64; out_name: string; if is_snagx { ok: bool; error: string; m, out_bytes, ok, error = snagx_to_mcap(data); if !ok return .FAILED, error, ""; hash = import_hash(m.original); out_name = tprint("%.%", path_strip_extension(name), MCAP_EXTENSION); } else { hash = import_hash(bytes); } // Once in the whole library: the hash is held for this file until it is written, or let go. claimed, where := import_claim(hash); if !claimed return .DUPLICATE, where, ""; let_go := true; defer if let_go import_let_go(hash); if !is_snagx { ok: bool; error: string; m, out_name, ok, error = import_image_mcap(path, name, bytes, kind, hash, modified); if !ok return .FAILED, error, ""; serializer, serialized, serialize_error := mcap_serialize(m,, temp); if !serialized return .FAILED, serialize_error, ""; out_bytes = cast([] u8) string.{serializer.count, serializer.data}; } out_path := import_reserve_path(out_name); written, write_error := mcap_write(out_path, out_bytes); new_path := import_written(out_path, hash, written); if !written return .FAILED, write_error, ""; let_go = false; return .IMPORTED, "", new_path; } // A video: hashed and copied into its .mcap a few MB at a time, so it can be larger than memory. import_video_file :: (path: string, name: string, head: [] u8, size: s64, modified: Apollo_Time) -> Import_Outcome, text: string, new_path: string { if OS != .WINDOWS return .SKIPPED, "video (Windows only)", ""; hash, hashed := import_hash_file(.{path = path, offset = 0, size = size}); if !hashed return .FAILED, "cannot be read", ""; claimed, where := import_claim(hash); if !claimed return .DUPLICATE, where, ""; let_go := true; defer if let_go import_let_go(hash); m, out_name, ok, error := import_video_mcap(path, name, head, size, hash, modified); if !ok return .FAILED, error, ""; out_path := import_reserve_path(out_name); written, write_error := mcap_write_streamed(out_path, m, .{path = path, offset = 0, size = size}); new_path := import_written(out_path, hash, written); if !written return .FAILED, write_error, ""; let_go = false; return .IMPORTED, "", new_path; } // Holds hash for a file being imported, so no other worker imports the same bytes; false, with where // they already are, when the library has them, or another file of this import does. import_claim :: (hash: u64) -> claimed: bool, where: string { lock(*importer.mutex); defer unlock(*importer.mutex); found, existing := table_find(*importer.by_hash, hash); if found return false, ifx existing then copy_string(existing,, temp) else "(another file in this import)"; table_set(*importer.by_hash, hash, ""); // "" while it is being written return true, ""; } // Lets go of a hash claimed for a file that was not imported after all. import_let_go :: (hash: u64) { lock(*importer.mutex); table_remove(*importer.by_hash, hash); unlock(*importer.mutex); } // Where out_name goes in the library folder, held until import_written: another file there by that // name (a different capture) gets _2, _3 ... Heap. import_reserve_path :: (out_name: string) -> string { lock(*importer.mutex); defer unlock(*importer.mutex); base := path_strip_extension(out_name); out_path := tprint("%/%", importer.dest_folder, out_name); number := 2; while file_exists(out_path) || table_contains(*importer.reserved_names, out_path) { out_path = tprint("%/%_%.%", importer.dest_folder, base, number, MCAP_EXTENSION); number += 1; } out_path = copy_string(out_path); table_set(*importer.reserved_names, out_path, true); return out_path; } // After out_path (from import_reserve_path, which this frees) was written, or failed to be: the // index learns of it. Where it is (temp), for the grid. import_written :: (out_path: string, hash: u64, written: bool) -> string { out_modified, out_size, _ := file_modtime_and_size(out_path); relative := import_relative(out_path); lock(*importer.mutex); table_remove(*importer.reserved_names, out_path); if written { table_set(*importer.by_hash, hash, copy_string(relative)); import_record_set(relative, .{size = out_size, modified = to_nanoseconds(out_modified), hash = hash}); } unlock(*importer.mutex); new_path := tprint("%/%", importer.library, relative); free(out_path); return new_path; } // An image file as an .mcap (temp) and the name it gets: the date it was taken and its hash. import_image_mcap :: (path: string, name: string, bytes: [] u8, kind: Image_Kind, hash: u64, modified: Apollo_Time) -> Mcap, name: string, ok: bool, error: string { m: Mcap; image, decoded, decode_error := image_decode(bytes, kind); if !decoded return m, "", false, decode_error; defer image_free(*image); png, preview_w, preview_h, preview_ok, preview_error := image_preview_png(image, MCAP_PREVIEW_MAX_SIDE); if !preview_ok return m, "", false, preview_error; exif := exif_read(bytes, kind); // When it was taken: the camera's clock, else a date in the file's name, else when the file was last changed. date, date_from := import_exif_date(exif.date_original), "EXIF"; if !date { date = import_name_date(name); date_from = "file name"; } if !date { date = import_calendar_text(to_calendar(modified, .LOCAL)); date_from = "modified time"; } make := import_text(exif.make); doc, info := import_document(path, name, import_device(make, exif.model), date, bytes.count, modified, hash, image_kind_name(kind), image.width, image.height, date_from); if kind == .GIF { frames, duration_ms := gif_info(bytes); if frames > 1 { // an animation import_add_number(doc, info, "Frames", xx frames); import_add_number(doc, info, "Duration", duration_ms / 1000.0); } } if exif.found { import_add_number(doc, info, "Orientation", xx exif.orientation); if make import_add(doc, info, "CameraMake", make); if exif.model import_add(doc, info, "CameraModel", import_text(exif.model)); if exif.software import_add(doc, info, "Software", import_text(exif.software)); if exif.offset_original import_add(doc, info, "TimeZoneOffset", import_text(exif.offset_original)); if exif.has_gps { import_add_number(doc, info, "Latitude", exif.latitude); import_add_number(doc, info, "Longitude", exif.longitude); if exif.altitude != 0 import_add_number(doc, info, "Altitude", exif.altitude); } } text, text_ok := import_document_text(doc, path); if !text_ok return m, "", false, "its Capture Text does not read back cleanly (see above)"; m.text = text; m.preview = .{width = cast(u16) preview_w, height = cast(u16) preview_h, png = png}; m.original_name = name; m.original = bytes; m.rendered_kind = .NONE; return m, import_out_name(date, hash), true, ""; } // A video as an .mcap (temp), but for its original, which mcap_write_streamed copies from the file, // and the name it gets. Its rendered image is a frame from it, which OCR and labels read. import_video_mcap :: (path: string, name: string, head: [] u8, size: s64, hash: u64, modified: Apollo_Time) -> Mcap, name: string, ok: bool, error: string { m: Mcap; image, video, decoded, decode_error := video_poster(path); if !decoded return m, "", false, decode_error; defer image_free(*image); png, preview_w, preview_h, preview_ok, preview_error := image_preview_png(image, MCAP_PREVIEW_MAX_SIDE); if !preview_ok return m, "", false, preview_error; poster, _, _, poster_ok, poster_error := image_preview_png(image, max(image.width, image.height)); // at its own size if !poster_ok return m, "", false, poster_error; // When it was taken: what the phone wrote (local time where it was), else a date in the file's name // (local time too, as recorders write it), else when the container was made (UTC, so turned into // this computer's time zone, which may not be where it was), else when the file was last changed. date, offset := import_video_local_date(video.creation_date); date_from := "metadata"; if !date { date = import_name_date(name); date_from = "file name"; } if !date { date = import_video_utc_date(video.creation_time); date_from = "metadata"; } if !date { date = import_calendar_text(to_calendar(modified, .LOCAL)); date_from = "modified time"; } make := import_text(video.make); doc, info := import_document(path, name, import_device(make, video.model), date, size, modified, hash, video_format_name(head, name), image.width, image.height, date_from); import_add_number(doc, info, "Duration", import_milliseconds(video.duration)); if video.frame_rate > 0 import_add_number(doc, info, "FrameRate", video.frame_rate); import_add(doc, info, "VideoCodec", import_text(video.codec)); import_add_number(doc, info, "Rotation", xx video.rotation); import_add_number(doc, info, "PosterTime", import_milliseconds(video.poster_time)); capture_object_add(doc, *info.root, "Audio", capture_boolean(video.has_audio)); if make import_add(doc, info, "CameraMake", make); if video.model import_add(doc, info, "CameraModel", import_text(video.model)); if video.software && !begins_with(video.software, "Lavf") import_add(doc, info, "Software", import_text(video.software)); // not FFmpeg's own tag if offset import_add(doc, info, "TimeZoneOffset", offset); latitude, longitude, altitude, has_altitude, located := iso6709_parse(video.location); if located { import_add_number(doc, info, "Latitude", latitude); import_add_number(doc, info, "Longitude", longitude); if has_altitude && altitude != 0 import_add_number(doc, info, "Altitude", altitude); } text, text_ok := import_document_text(doc, path); if !text_ok return m, "", false, "its Capture Text does not read back cleanly (see above)"; m.text = text; m.preview = .{width = cast(u16) preview_w, height = cast(u16) preview_h, png = png}; m.original_name = name; m.rendered_kind = .OWN; m.rendered = poster; return m, import_out_name(date, hash), true, ""; } // An imported file's Capture Text so far (temp): metadata.json, and import.json with the keys every // import has; the caller adds its own to info (import_add), then import_document_text. import_document :: (path: string, name: string, device: string, date: string, size: s64, modified: Apollo_Time, hash: u64, format: string, width: s64, height: s64, date_from: string) -> *Capture_Document, info: *Capture_File { doc := New(Capture_Document,, temp); capture_document_init(doc, temp); doc.comment = tprint("Imported by % % from %.", APP_NAME, VERSION, import_text(path_filename(path))); metadata := capture_add_file(doc, "metadata.json"); import_add(doc, metadata, "AppName", device); import_add(doc, metadata, "CaptureDate", date); import_add(doc, metadata, "WindowName", import_text(name)); info := capture_add_file(doc, "import.json"); import_add(doc, info, "SourcePath", import_text(path)); import_add_number(doc, info, "SourceSize", xx size); import_add(doc, info, "SourceModified", import_calendar_text(to_calendar(modified, .LOCAL))); import_add(doc, info, "ContentHash", import_hash_text(hash)); import_add(doc, info, "Format", format); import_add_number(doc, info, "Width", xx width); import_add_number(doc, info, "Height", xx height); import_add(doc, info, "DateFrom", date_from); import_add(doc, info, "ImportDate", import_calendar_text(to_calendar(current_time_consensus(), .LOCAL))); return doc, info; } import_add :: (doc: *Capture_Document, file: *Capture_File, key: string, value: string) { capture_object_add(doc, *file.root, key, capture_string(doc, value)); } import_add_number :: (doc: *Capture_Document, file: *Capture_File, key: string, value: float64) { capture_object_add(doc, *file.root, key, capture_number(value)); } // Seconds to the millisecond: 1.667, not 1.6666666666666668. import_milliseconds :: (seconds: float64) -> float64 { return floor(seconds * 1000 + 0.5) / 1000; } // The document as Capture Text (temp), once it reads back as written. import_document_text :: (doc: *Capture_Document, path: string) -> string, ok: bool { text := capture_text_write(doc); back: Capture_Document; capture_document_init(*back, temp); if !capture_text_parse(*back, path, text) || back.mistakes || back.files.count != 2 return "", false; return text, true; } // The camera or phone, as AppName: "Apple iPhone 15 Pro", or its model alone when that starts with // its make. import_device :: (make: string, model: string) -> string { device := import_text(model); if make && !begins_with_nocase(device, make) device = tprint("% %", make, device); return trim(device); } // 2024-05-12_10-15-30_1a2b3c4d.mcap: newest last, as Snagit names its captures. import_out_name :: (date: string, hash: u64) -> string { stamp := replace(replace(date, " ", "_",, temp), ":", "-",, temp); return tprint("%_%.%", stamp, slice(import_hash_text(hash), 0, 8), MCAP_EXTENSION); } // Apple's creationdate, "2024-05-12T10:15:30+0100": the local time it was taken, as Snagit writes // CaptureDate, and its offset from UTC, "+01:00" ("" when it has none). "" when it is not a date. import_video_local_date :: (text: string) -> date: string, offset: string { date := import_exif_date(text); if !date return "", ""; rest := advance(text, 19); while rest.count && (rest[0] == #char "." || is_digit(rest[0])) rest = advance(rest, 1); // fractions of a second offset := ""; if rest.count >= 5 && (rest[0] == #char "+" || rest[0] == #char "-") && is_digit(rest[1]) && is_digit(rest[2]) { minutes := ifx rest[3] == #char ":" then slice(rest, 4, 2) else slice(rest, 3, 2); if minutes.count == 2 && is_digit(minutes[0]) && is_digit(minutes[1]) offset = tprint("%:%", slice(rest, 0, 3), minutes); } else if rest.count && rest[0] == #char "Z" { offset = "+00:00"; } return date, offset; } // A container's creation_time, "2024-05-12T09:15:30.000000Z", which is UTC, as local time in the form // Snagit writes CaptureDate. "" when it is not a date, or is the zero date a camera with no clock // writes (1904 or 1970). import_video_utc_date :: (text: string) -> string { date := import_exif_date(text); if !date return ""; year := string_to_int(slice(date, 0, 4)); if year <= 1970 return ""; utc: Calendar_Time; utc.year = cast(s32) year; utc.month_starting_at_0 = cast(s8) (string_to_int(slice(date, 5, 2)) - 1); utc.day_of_month_starting_at_0 = cast(s8) (string_to_int(slice(date, 8, 2)) - 1); utc.hour = cast(s8) string_to_int(slice(date, 11, 2)); utc.minute = cast(s8) string_to_int(slice(date, 14, 2)); utc.second = cast(s8) string_to_int(slice(date, 17, 2)); utc.time_zone = .UTC; return import_calendar_text(to_calendar(calendar_to_apollo(utc), .LOCAL)); } import_hash :: (bytes: [] u8) -> u64 { return fnv1a_hash(bytes.data, bytes.count); } // import_hash of the bytes ref points at, read a few MB at a time: the same hash, as FNV-1a carries // on from where it left off. import_hash_file :: (ref: Mcap_Original_Ref) -> u64, ok: bool { CHUNK :: 4 * 1024 * 1024; file, opened := file_open(ref.path, log_errors = false); if !opened return 0, false; defer file_close(*file); if !file_set_position(file, ref.offset) return 0, false; buffer := NewArray(min(ref.size, CHUNK), u8, initialized = false,, temp); hash := FNV_64_OFFSET_BIAS; left := ref.size; while left > 0 { count := min(left, CHUNK); ok, read := file_read(file, buffer.data, count); if !ok || read != count return 0, false; hash = fnv1a_hash(buffer.data, count, hash); left -= count; } return hash, true; } import_hash_text :: (hash: u64) -> string { return tprint("%", formatInt(hash, base = 16, minimum_digits = 16)); } // "2024-05-12 10:15:30", as Snagit writes CaptureDate. Temp. import_calendar_text :: (c: Calendar_Time) -> string { two :: (n: s64) -> string { return tprint("%", formatInt(n, minimum_digits = 2)); } // a string: a FormatInt would point at n return tprint("%-%-% %:%:%", c.year, two(c.month_starting_at_0 + 1), two(c.day_of_month_starting_at_0 + 1), two(c.hour), two(c.minute), two(c.second)); } // EXIF's "2024:05:12 10:15:30" as "2024-05-12 10:15:30"; "" when it is not a date (cameras with no // clock set write zeros or blanks). import_exif_date :: (text: string) -> string { if text.count < 19 return ""; digits := "0000:00:00 00:00:00"; for 0..18 { if digits[it] == #char "0" { if !is_digit(text[it]) return ""; } else if text[it] != #char ":" && text[it] != #char "-" && text[it] != #char " " && text[it] != #char "T" return ""; } year := string_to_int(slice(text, 0, 4)); month := string_to_int(slice(text, 5, 2)); day := string_to_int(slice(text, 8, 2)); if year < 1900 || month < 1 || month > 12 || day < 1 || day > 31 return ""; return tprint("%-%-% %", slice(text, 0, 4), slice(text, 5, 2), slice(text, 8, 2), slice(text, 11, 8)); } // A date, and a time when there is one, in a file's name, as phones and screenshot tools write them: // IMG_20240512_101530, PXL_20240512_101530123, Screenshot_20240512-101530, IMG-20240512-WA0001, // Screenshot 2024-05-12 at 10.15.30, Screenshot_2024-05-12-10-15-30, 2024-05-12_10-15-30. "" for none. import_name_date :: (name: string) -> string { digits_at :: (s: string, at: s64, count: s64) -> s64 { if at < 0 || at + count > s.count return -1; for at..at + count - 1 if !is_digit(s[it]) return -1; return string_to_int(slice(s, at, count)); } separator :: (s: string, at: s64) -> bool { return at < s.count && (s[at] == #char "-" || s[at] == #char "_" || s[at] == #char "." || s[at] == #char " " || s[at] == #char ":"); } for start: 0..name.count - 8 { if start > 0 && is_digit(name[start - 1]) continue; // the year starts a run of digits year := digits_at(name, start, 4); if year < 1990 || year > 2099 continue; at := start + 4; if separator(name, at) at += 1; month := digits_at(name, at, 2); at += 2; if separator(name, at) at += 1; day := digits_at(name, at, 2); at += 2; if month < 1 || month > 12 || day < 1 || day > 31 continue; if at < name.count && is_digit(name[at]) && !(at + 6 <= name.count && digits_at(name, at, 6) >= 0) continue; // The time: after a separator, or " at ", or nothing. hour, minute, second := 0, 0, 0; t := at; if begins_with(advance(name, t), " at ") t += 4; else if t < name.count && (separator(name, t) || name[t] == #char "T") t += 1; h := digits_at(name, t, 2); if h >= 0 && h < 24 { t2 := t + 2; if separator(name, t2) t2 += 1; m := digits_at(name, t2, 2); t3 := t2 + 2; if separator(name, t3) t3 += 1; s := digits_at(name, t3, 2); if m >= 0 && m < 60 && s >= 0 && s < 60 { hour, minute, second = h, m, s; } } two :: (n: s64) -> string { return tprint("%", formatInt(n, minimum_digits = 2)); } // a string: a FormatInt would point at n return tprint("%-%-% %:%:%", year, two(month), two(day), two(hour), two(minute), two(second)); } return ""; } // Text from a file (a name, an EXIF field) made safe for Capture Text: control characters as spaces, // and bytes that are not UTF-8 (EXIF written in Latin-1) as ?. Temp. import_text :: (text: string) -> string { if !text return ""; out := copy_string(text,, temp); for * out if it.* < 32 || it.* == 127 it.* = #char " "; if !is_valid_utf8(out) for * out if it.* >= 128 it.* = #char "?"; return trim(out); } import_read_head :: (path: string, count: s64) -> [] u8 { file, ok := file_open(path, log_errors = false); if !ok return .[]; defer file_close(*file); head := NewArray(count, u8,, temp); read_ok, read := file_read(file, head.data, count); if !read_ok return .[]; head.count = read; return head; } // path inside the library, as the index keeps it: "SnagitLibraryWork/2024-05-12_10-15-30.mcap". import_relative :: (path: string) -> string { library := importer.library; if path.count > library.count + 1 && begins_with_nocase(path, library) && (path[library.count] == #char "/" || path[library.count] == #char "\\") return advance(path, library.count + 1); return path; } import_elapsed :: () -> float64 { elapsed := to_float64_seconds(current_time_monotonic() - importer.start) - importer.paused_seconds; if importer.paused elapsed -= to_float64_seconds(current_time_monotonic() - importer.pause_start); return elapsed; } // ---------------------------------------------------------------- the library index // Brings the index up to date with the library: files it does not know, or that changed, have their // original hashed; files gone are dropped. False, with importer.error, when it cannot. import_index_update :: () -> bool { lock(*importer.mutex); for _, key: importer.records free(key); table_reset(*importer.records); for value: importer.by_hash free(value); table_reset(*importer.by_hash); unlock(*importer.mutex); // What the index says. index_path := tprint("%/%", importer.library, IMPORT_INDEX_NAME); data, read_ok := read_entire_file(index_path, log_errors = false,, temp); if read_ok { lines := split(data, "\n",, temp); if lines.count && trim(lines[0]) == "@version 1" { for 1..lines.count - 1 { parts := split(trim_right(lines[it], "\r"), "\t",, temp); if parts.count != 4 continue; hash, hash_ok := string_to_int(parts[0], 16, u64); size, size_ok := string_to_int(parts[1]); modified, modified_ok := string_to_int(parts[2]); if hash_ok && size_ok && modified_ok import_record_set(parts[3], .{size, modified, hash}); } } } // What the library holds. stale: [..] Stale_File; stale.allocator = temp; present: Table(string, bool); present.allocator = temp; for file_list(importer.library, recursive = true,, temp) { if !ends_with_nocase(it, tprint(".%", MCAP_EXTENSION)) continue; relative := import_relative(it); table_set(*present, relative, true); modified, size, ok := file_modtime_and_size(it); if !ok continue; nanoseconds := to_nanoseconds(modified); found, record := table_find(*importer.records, relative); if found && record.size == size && record.modified == nanoseconds continue; array_add(*stale, .{copy_string(relative,, temp), size, nanoseconds}); } gone: [..] string; gone.allocator = temp; for _, key: importer.records if !table_contains(*present, key) array_add(*gone, key); for gone { table_remove(*importer.records, it); free(it,, context.default_allocator); } // The stale ones hashed, on the workers. index_stale = stale; importer.index_total = stale.count; importer.index_done = 0; index_next = 0; if stale.count log("[Import] Indexing % of the library's .mcap files.", stale.count); import_run_workers(import_index_worker); if importer.cancel return false; lock(*importer.mutex); for record, relative: importer.records { if !table_contains(*importer.by_hash, record.hash) table_set(*importer.by_hash, record.hash, copy_string(relative)); } unlock(*importer.mutex); import_index_save(); return true; } import_index_worker :: (thread: *Thread) -> s64 { context.allocator = context.default_allocator; while true { while importer.paused && !importer.cancel sleep_milliseconds(100); if importer.cancel break; n := atomic_add(*index_next, 1); if n >= index_stale.count break; item := index_stale[n]; path := tprint("%/%", importer.library, item.relative); // The original is hashed where it lies, a few MB at a time: it may be a video larger than memory. m, original, parsed, error := mcap_read_without_original(path,, temp); hash: u64; hashed: bool; if parsed hash, hashed = import_hash_file(original); if hashed { lock(*importer.mutex); import_record_set(item.relative, .{item.size, item.modified, hash}); unlock(*importer.mutex); } else { log_error("[Import] % is not a readable .mcap (%); it is left out of the index.", path, ifx parsed then "its original cannot be read" else error); } atomic_add(*importer.index_done, 1); reset_temporary_storage(); } return 0; } import_record_set :: (relative: string, record: Index_Record) { pointer := table_find_pointer(*importer.records, relative); if pointer { pointer.* = record; return; } table_set(*importer.records, copy_string(relative,, context.default_allocator), record); } // Writes the index beside itself and swaps it in. import_index_save :: () { builder: String_Builder; builder.allocator = temp; append(*builder, "@version 1\n"); lock(*importer.mutex); for record, relative: importer.records { print_to_builder(*builder, "%\t%\t%\t%\n", import_hash_text(record.hash), record.size, record.modified, relative); } unlock(*importer.mutex); path := tprint("%/%", importer.library, IMPORT_INDEX_NAME); temp_path := tprint("%.tmp", path); if !write_entire_file(temp_path, *builder) || !atomic_file_replace_or_just_rename_if_destination_file_does_not_exist(temp_path, path) { log_error("[Import] Could not write %.", path); } } // ---------------------------------------------------------------- where each library folder came from import_sources_load :: () { data, ok := read_entire_file(tprint("%/%", viewer_folder(), IMPORT_SOURCES_NAME), log_errors = false,, temp); if !ok return; for split(data, "\n",, temp) { parts := split(trim_right(it, "\r"), "\t",, temp); if parts.count != 2 || !parts[0].count continue; table_set(*importer.sources, copy_string(parts[0],, context.default_allocator), copy_string(parts[1],, context.default_allocator)); } } import_sources_save :: () { builder: String_Builder; builder.allocator = temp; for source, dest: importer.sources print_to_builder(*builder, "%\t%\n", dest, source); write_entire_file(tprint("%/%", viewer_folder(), IMPORT_SOURCES_NAME), *builder); } // The library folder a source goes into: the one it went into last time, else its own folder's name. import_dest_for :: (source: string) -> string { normal := copy_string(trim(source),, temp); path_overwrite_separators(normal, #char "/"); while normal.count > 3 && normal[normal.count - 1] == #char "/" normal.count -= 1; for value, key: importer.sources if to_lower_copy(value,, temp) == to_lower_copy(normal,, temp) return key; name := path_filename(normal); return ifx import_name_is_valid(name) then name else ""; } // ---------------------------------------------------------------- each frame import_frame :: () { using ImGui; import_take_new_paths(); if app.import_source import_flag_frame(); // Ended: the scan of what came in, when asked for. if !importer.running && importer.thread_started && importer.phase == .DONE { thread_is_done(*importer.thread, -1); thread_deinit(*importer.thread); importer.thread_started = false; if importer.error toast_show(.Error, "Import: %", importer.error); else toast_show(.Success, "Imported % into % (% duplicates, % skipped, % failed)", importer.imported, importer.dest_name, importer.duplicates, importer.skipped, importer.failed); importer.scan_pending = importer.read_after && importer.new_indices.count > 0 && !app.import_source; importer.segment_pending = library_folder_segments(importer.dest_name) && importer.new_indices.count > 0 && !app.import_source; } if importer.scan_pending && !ocr_ui.thread_busy && ocr_ui.engine && labels_ui_settled() { importer.scan_pending = false; ocr_scan_start(importer.new_indices); } // Then segmentation (app/segment_ui.jai), once the new captures' catalog entries say which are videos. if importer.segment_pending && !importer.scan_pending && !ocr_ui.thread_busy && !ocr_ui.scan.running { catalogued := true; for importer.new_indices if !viewer.captures[it].catalog && !viewer.captures[it].removed { catalogued = false; break; } if catalogued { importer.segment_pending = false; segment_scan_start(importer.new_indices, quiet = true); } } if !importer.window_open return; SetNextWindowSize(.{720, 520}, .FirstUseEver); if Begin(IMPORT_WINDOW, *importer.window_open) { import_window(); } End(); } // The captures written since the last frame, into the grid. import_take_new_paths :: () { lock(*importer.mutex); paths := array_copy(importer.new_paths,, temp); array_reset_keeping_memory(*importer.new_paths); unlock(*importer.mutex); for paths { index := viewer_add_capture(it); if index >= 0 array_add(*importer.new_indices, index); free(it,, context.default_allocator); } } // -import SOURCE DEST: imports once the viewer has started, then ends the app (1 if any file failed). import_flag_frame :: () { if !importer.flag_started { importer.flag_started = true; importer.read_after = false; if !import_start(app.import_source, app.import_dest) { log_error("[Import] -import % % could not start.", app.import_source, app.import_dest); app.exit_code = 2; app.should_exit = true; app.import_source = ""; } return; } if importer.running || importer.thread_started return; log("[Import] -import is done: % imported, % duplicates, % skipped, % failed, in %.", importer.imported, importer.duplicates, importer.skipped, importer.failed, ocr_duration_text(importer.elapsed_seconds)); app.exit_code = ifx importer.failed || importer.error then cast(s32) 1 else 0; app.should_exit = true; app.import_source = ""; } import_window :: () { using ImGui; running := importer.running; TextColored(app_theme.palette.dim, "Imports every image (PNG, JPEG, HEIC, AVIF, WebP, GIF, BMP, TIFF, camera RAW), video (MP4, MOV, MKV, WebM, AVI, WMV, MPEG) and Snagit .snagx capture under a folder into a folder of its own in %. The files there are only read. Each is stored once in the whole library.", viewer_folder()); Spacing(); if running BeginDisabled(); SetNextItemWidth(-200); if InputTextWithHint("##source", "The folder to import, e.g. D:\\Snagit or E:\\Phone backup\\DCIM", importer.source_buffer.data, importer.source_buffer.count) { import_source_changed(); } SameLine(); #if OS == .WINDOWS { if Button("Browse...") { chosen := windows_choose_folder("Choose a folder to import"); if chosen { count := min(chosen.count, importer.source_buffer.count - 1); memcpy(importer.source_buffer.data, chosen.data, count); importer.source_buffer[count] = 0; import_source_changed(); } } SameLine(); } Text("Import from"); SetNextItemWidth(-200); if InputTextWithHint("##dest", "A folder in the library, e.g. SnagitLibraryWork", importer.dest_buffer.data, importer.dest_buffer.count) { importer.dest_edited = importer.dest_buffer[0] != 0; } SameLine(); Text("Into library folder"); source := to_string(importer.source_buffer.data); dest := trim(to_string(importer.dest_buffer.data)); if dest { if !import_name_is_valid(dest) TextColored(app_theme.palette.warning, "\"%\" cannot be a folder's name.", dest); else TextColored(app_theme.palette.dim, "Into %/%", viewer_folder(), dest); } Checkbox("Read text and labels in what is imported, once it is in", *importer.read_after); if dest && import_name_is_valid(dest) { // The library folder's own option (app/library_folders.jai): it stays with the folder. segment := library_folder_segments(dest); if Checkbox("Segment the images in this folder (what is where: people, animals, things, sky...)", *segment) library_folder_set_segment(dest, segment); if IsItemHovered() SetTooltip("Kept for the library folder: the Segmentation window has it too. Videos are never segmented."); } if running EndDisabled(); Spacing(); if !running { can_start := trim(source).count > 0 && dest.count > 0 && import_name_is_valid(dest); if !can_start BeginDisabled(); if Button("Import") import_start(source, dest); if !can_start EndDisabled(); } else { if importer.paused { if Button("Resume") { importer.paused_seconds += to_float64_seconds(current_time_monotonic() - importer.pause_start); atomic_swap(*importer.paused, false); } } else { if Button("Pause") { importer.pause_start = current_time_monotonic(); atomic_swap(*importer.paused, true); } } SameLine(); if Button("Cancel") { if importer.paused importer.paused_seconds += to_float64_seconds(current_time_monotonic() - importer.pause_start); atomic_swap(*importer.cancel, true); atomic_swap(*importer.paused, false); } } if importer.phase == .IDLE return; Separator(); if importer.error TextColored(app_theme.palette.warning, "%", importer.error); elapsed := ifx running then import_elapsed() else importer.elapsed_seconds; if importer.phase == { case .LISTING; Text("Looking through %: % files so far", importer.source, importer.listed); case .INDEXING; Text("Indexing the library (once; this finds duplicates):"); ProgressBar(cast(float) importer.index_done / max(importer.index_total, 1), .{-FLOAT32_MIN, 0}, temp_c_string(ocr_scan_progress_text(importer.index_done, importer.index_total))); case; total := importer.files.count; done := importer.done; rate := ifx elapsed > 0 then done / elapsed else 0; left := ifx rate > 0 then (total - done) / rate else 0; if running { Text("Importing % into %:", importer.source, importer.dest_name); ProgressBar(cast(float) done / max(total, 1), .{-FLOAT32_MIN, 0}, temp_c_string(ocr_scan_progress_text(done, total))); Text("% elapsed, % left, % files/s%", ocr_duration_text(elapsed), ocr_duration_text(left), FF(rate, 1), ifx importer.paused then " (paused)" else ""); } else { Text("Last import, % into %: % of % files in %%.", importer.source, importer.dest_name, done, total, ocr_duration_text(elapsed), ifx importer.cancel then " (cancelled)" else ""); } } Text("% imported, % duplicates, % skipped, % failed", importer.imported, importer.duplicates, importer.skipped, importer.failed); lock(*importer.mutex); defer unlock(*importer.mutex); if importer.failures.count && CollapsingHeader(temp_c_string(tprint("Failed (%)###failed", importer.failed))) { if SmallButton("Copy the list##failed") clipboard_set_text(import_notes_text(importer.failures)); BeginChild("failed", .{0, GetTextLineHeightWithSpacing() * min(cast(float) importer.failures.count + 1, 12)}); import_list(importer.failures, "% %"); EndChild(); } if importer.duplicate_notes.count && CollapsingHeader(temp_c_string(tprint("Duplicates (%), already in the library###duplicates", importer.duplicates))) { if SmallButton("Copy the list##duplicates") clipboard_set_text(import_notes_text(importer.duplicate_notes)); BeginChild("duplicates", .{0, GetTextLineHeightWithSpacing() * min(cast(float) importer.duplicate_notes.count + 1, 12)}); import_list(importer.duplicate_notes, "% is %"); EndChild(); } if importer.skipped && CollapsingHeader(temp_c_string(tprint("Skipped (%), by kind###skipped", importer.skipped))) { kinds: [..] string; kinds.allocator = temp; for _, key: importer.skipped_kinds array_add(*kinds, key); quick_sort(kinds, compare); for kinds { _, count := table_find(*importer.skipped_kinds, it); Text("%: %", it, count); } } } // The notes, a line each, only those in view drawn. import_list :: (notes: [] Import_Note, format: string) { using ImGui; clipper: ListClipper; clipper.Constructor(*clipper); defer clipper.Destructor(*clipper); clipper.Begin(*clipper, xx notes.count); while clipper.Step(*clipper) { for clipper.DisplayStart..clipper.DisplayEnd - 1 TextUnformatted(tprint(format, notes[it].path, notes[it].text)); } clipper.End(*clipper); } import_notes_text :: (notes: [] Import_Note) -> string { builder: String_Builder; builder.allocator = temp; for notes print_to_builder(*builder, "%\t%\n", it.path, it.text); return builder_to_string(*builder,, temp); } // A new source: the library folder it goes into, unless one was typed. import_source_changed :: () { if importer.dest_edited && importer.dest_buffer[0] return; dest := import_dest_for(to_string(importer.source_buffer.data)); count := min(dest.count, importer.dest_buffer.count - 1); memcpy(importer.dest_buffer.data, dest.data, count); importer.dest_buffer[count] = 0; importer.dest_edited = false; } #scope_file Stale_File :: struct { relative: string; size: s64; modified: s64; } index_stale: [] Stale_File; index_next: s64;