From 8bfb0219397af0ee13a93e0f9b7dadbe594d8028 Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Tue, 4 Nov 2025 15:53:31 +0200 Subject: [PATCH] feat: Support atlas_client module: - Add client for atlas module - Add unit tests to test the workflow - Remove println statements from file_or_image_exists - Remove println statements from link processing loop --- lib/data/atlas/collection.v | 2 +- lib/data/atlas/link.v | 2 - lib/web/atlas_client/README.md | 93 +++ lib/web/atlas_client/client.v | 422 ++++++++++++++ lib/web/atlas_client/client_test.v | 670 ++++++++++++++++++++++ lib/web/atlas_client/error.v | 169 ++++++ lib/web/atlas_client/error_test.v | 268 +++++++++ lib/web/atlas_client/extract_links.v | 55 ++ lib/web/atlas_client/extract_links_test.v | 298 ++++++++++ lib/web/atlas_client/factory.v | 22 + lib/web/atlas_client/model.v | 12 + 11 files changed, 2010 insertions(+), 3 deletions(-) create mode 100644 lib/web/atlas_client/README.md create mode 100644 lib/web/atlas_client/client.v create mode 100644 lib/web/atlas_client/client_test.v create mode 100644 lib/web/atlas_client/error.v create mode 100644 lib/web/atlas_client/error_test.v create mode 100644 lib/web/atlas_client/extract_links.v create mode 100644 lib/web/atlas_client/extract_links_test.v create mode 100644 lib/web/atlas_client/factory.v create mode 100644 lib/web/atlas_client/model.v diff --git a/lib/data/atlas/collection.v b/lib/data/atlas/collection.v index c2dd63a3..80dae74e 100644 --- a/lib/data/atlas/collection.v +++ b/lib/data/atlas/collection.v @@ -148,7 +148,7 @@ pub fn (c Collection) file_exists(name string) bool { } pub fn (c Collection) file_or_image_exists(name string) bool { - f := c.files[name] or { return false } + _ := c.files[name] or { return false } return true } diff --git a/lib/data/atlas/link.v b/lib/data/atlas/link.v index 56d94dcb..622b1794 100644 --- a/lib/data/atlas/link.v +++ b/lib/data/atlas/link.v @@ -118,7 +118,6 @@ fn (mut p Page) find_links(content string) ![]Link { link.is_file_link = false link.is_image_link = false } - println(link) links << link pos = close_paren + 1 @@ -232,7 +231,6 @@ fn (mut p Page) process_links(mut export_dir pathlib.Path) !string { // Process links in reverse order to maintain string positions for mut link in links.reverse() { - println(link) if link.status != .found { continue } diff --git a/lib/web/atlas_client/README.md b/lib/web/atlas_client/README.md new file mode 100644 index 00000000..6e202e94 --- /dev/null +++ b/lib/web/atlas_client/README.md @@ -0,0 +1,93 @@ +# AtlasClient + +A simple API for accessing document collections exported by the `atlas` module. + +## What It Does + +AtlasClient provides methods to: + +- List collections, pages, files, and images +- Check if resources exist +- Get file paths and content +- Access metadata (links, errors) +- Copy images from pages + +## Quick Start + +```v +import incubaid.herolib.web.atlas_client + +// Create client +mut client := atlas_client.new(export_dir: '/tmp/atlas_export')! + +// List collections +collections := client.list_collections()! + +// Get page content +content := client.get_page_content('my_collection', 'page_name')! + +// Check for errors +if client.has_errors('my_collection')! { + errors := client.get_collection_errors('my_collection')! +} +``` + +## Export Structure + +Atlas exports to this structure: + +```txt +export_dir/ +├── content/ +│ └── collection_name/ +│ ├── page.md +│ ├── image.png +│ └── file.pdf +└── meta/ + └── collection_name.json +``` + +## Key Methods + +**Collections:** + +- `list_collections()` - List all collections + +**Pages:** + +- `list_pages(collection)` - List pages in collection +- `page_exists(collection, page)` - Check if page exists +- `get_page_content(collection, page)` - Get page markdown content +- `get_page_path(collection, page)` - Get page file path + +**Files & Images:** + +- `list_files(collection)` - List non-page, non-image files +- `list_images(collection)` - List image files +- `get_file_path(collection, file)` - Get file path +- `get_image_path(collection, image)` - Get image path +- `copy_images(collection, page, dest)` - Copy page images to dest/img/ + +**Metadata:** + +- `get_collection_metadata(collection)` - Get full metadata +- `get_page_links(collection, page)` - Get links from page +- `get_collection_errors(collection)` - Get collection errors +- `has_errors(collection)` - Check if collection has errors + +## Naming Convention + +Names are normalized using `name_fix_no_underscore_no_ext()`: + +- `My_Page-Name.md` → `mypagename` +- Removes: underscores, dashes, special chars, extensions +- Converts to lowercase + +## Example + +See `examples/data/atlas_client/basic_usage.vsh` for a complete working example. + +## See Also + +- `lib/data/atlas/` - Atlas module for exporting collections +- `lib/web/doctreeclient/` - Alternative client for doctree collections diff --git a/lib/web/atlas_client/client.v b/lib/web/atlas_client/client.v new file mode 100644 index 00000000..966e680e --- /dev/null +++ b/lib/web/atlas_client/client.v @@ -0,0 +1,422 @@ +module atlas_client + +import incubaid.herolib.core.pathlib +import incubaid.herolib.core.texttools +import os +import json + +// List of recognized image file extensions +const image_extensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.tiff', '.ico'] + +// CollectionMetadata represents the metadata stored in meta/{collection}.json +pub struct CollectionMetadata { +pub mut: + name string + path string + pages map[string]PageMetadata + files map[string]FileMetadata + errors []ErrorMetadata +} + +pub struct PageMetadata { +pub mut: + name string + path string + collection_name string + links []LinkMetadata +} + +pub struct FileMetadata { +pub mut: + name string + path string +} + +pub struct LinkMetadata { +pub mut: + src string + text string + target string + line int + target_collection_name string + target_item_name string + status string + is_file_link bool + is_image_link bool +} + +pub struct ErrorMetadata { +pub mut: + category string + page_key string + message string + line int +} + +// get_page_path returns the path for a page in a collection +// Pages are stored in {export_dir}/content/{collection}/{page}.md +pub fn (mut c AtlasClient) get_page_path(collection_name string, page_name string) !string { + // Apply name normalization (atlas uses name_fix_no_underscore_no_ext) + fixed_collection_name := texttools.name_fix_no_underscore_no_ext(collection_name) + fixed_page_name := texttools.name_fix_no_underscore_no_ext(page_name) + + // Check if export directory exists + if !os.exists(c.export_dir) { + return c.error_export_dir_not_found(export_dir: c.export_dir) + } + + // Construct the page path + page_path := os.join_path(c.export_dir, 'content', fixed_collection_name, '${fixed_page_name}.md') + + // Check if the page file exists + if !os.exists(page_path) { + return c.error_page_not_found( + collection_name: collection_name + page_name: page_name + ) + } + + return page_path +} + +// get_file_path returns the path for a file in a collection +// Files are stored in {export_dir}/content/{collection}/{filename} +pub fn (mut c AtlasClient) get_file_path(collection_name string, file_name string) !string { + // Apply name normalization + fixed_collection_name := texttools.name_fix_no_underscore_no_ext(collection_name) + // Files keep their original names with extensions + fixed_file_name := texttools.name_fix_keepext(file_name) + + // Check if export directory exists + if !os.exists(c.export_dir) { + return c.error_export_dir_not_found(export_dir: c.export_dir) + } + + // Construct the file path + file_path := os.join_path(c.export_dir, 'content', fixed_collection_name, fixed_file_name) + + // Check if the file exists + if !os.exists(file_path) { + return c.error_file_not_found( + collection_name: collection_name + file_name: file_name + ) + } + + return file_path +} + +// get_image_path returns the path for an image in a collection +// Images are stored in {export_dir}/content/{collection}/{imagename} +pub fn (mut c AtlasClient) get_image_path(collection_name string, image_name string) !string { + // Apply name normalization + fixed_collection_name := texttools.name_fix_no_underscore_no_ext(collection_name) + // Images keep their original names with extensions + fixed_image_name := texttools.name_fix_keepext(image_name) + + // Check if export directory exists + if !os.exists(c.export_dir) { + return c.error_export_dir_not_found(export_dir: c.export_dir) + } + + // Construct the image path + image_path := os.join_path(c.export_dir, 'content', fixed_collection_name, fixed_image_name) + + // Check if the image exists + if !os.exists(image_path) { + return c.error_image_not_found( + collection_name: collection_name + image_name: image_name + ) + } + + return image_path +} + +// page_exists checks if a page exists in a collection +pub fn (mut c AtlasClient) page_exists(collection_name string, page_name string) bool { + // Try to get the page path - if it succeeds, the page exists + _ := c.get_page_path(collection_name, page_name) or { return false } + return true +} + +// file_exists checks if a file exists in a collection +pub fn (mut c AtlasClient) file_exists(collection_name string, file_name string) bool { + // Try to get the file path - if it succeeds, the file exists + _ := c.get_file_path(collection_name, file_name) or { return false } + return true +} + +// image_exists checks if an image exists in a collection +pub fn (mut c AtlasClient) image_exists(collection_name string, image_name string) bool { + // Try to get the image path - if it succeeds, the image exists + _ := c.get_image_path(collection_name, image_name) or { return false } + return true +} + +// get_page_content returns the content of a page in a collection +pub fn (mut c AtlasClient) get_page_content(collection_name string, page_name string) !string { + // Get the path for the page + page_path := c.get_page_path(collection_name, page_name)! + + // Use pathlib to read the file content + mut path := pathlib.get_file(path: page_path)! + + // Check if the file exists + if !path.exists() { + return c.error_page_file_not_exists(page_path: page_path) + } + + // Read and return the file content + return path.read()! +} + +// list_collections returns a list of all collection names +// Collections are directories in {export_dir}/content/ +pub fn (mut c AtlasClient) list_collections() ![]string { + content_dir := os.join_path(c.export_dir, 'content') + + // Check if content directory exists + if !os.exists(content_dir) { + return c.error_invalid_export_structure(content_dir: content_dir) + } + + // Get all subdirectories in content/ + mut collections := []string{} + entries := os.ls(content_dir)! + + for entry in entries { + entry_path := os.join_path(content_dir, entry) + if os.is_dir(entry_path) { + collections << entry + } + } + + return collections +} + +// list_pages returns a list of all page names in a collection +// Uses metadata to get the authoritative list of pages that belong to this collection +pub fn (mut c AtlasClient) list_pages(collection_name string) ![]string { + // Get metadata which contains the authoritative list of pages + metadata := c.get_collection_metadata(collection_name)! + + // Extract page names from metadata + mut page_names := []string{} + for page_name, _ in metadata.pages { + page_names << page_name + } + + return page_names +} + +// list_files returns a list of all file names in a collection (excluding pages and images) +pub fn (mut c AtlasClient) list_files(collection_name string) ![]string { + // Apply name normalization + fixed_collection_name := texttools.name_fix_no_underscore_no_ext(collection_name) + + collection_dir := os.join_path(c.export_dir, 'content', fixed_collection_name) + + // Check if collection directory exists + if !os.exists(collection_dir) { + return c.error_collection_not_found(collection_name: collection_name) + } + + // Get all files that are not .md and not images + mut file_names := []string{} + entries := os.ls(collection_dir)! + + for entry in entries { + entry_path := os.join_path(collection_dir, entry) + + // Skip directories + if os.is_dir(entry_path) { + continue + } + + // Skip .md files (pages) + if entry.ends_with('.md') { + continue + } + + // Check if it's an image + mut is_image := false + for ext in image_extensions { + if entry.ends_with(ext) { + is_image = true + break + } + } + + // Add to file_names if it's not an image + if !is_image { + file_names << entry + } + } + + return file_names +} + +// list_images returns a list of all image names in a collection +pub fn (mut c AtlasClient) list_images(collection_name string) ![]string { + // Apply name normalization + fixed_collection_name := texttools.name_fix_no_underscore_no_ext(collection_name) + + collection_dir := os.join_path(c.export_dir, 'content', fixed_collection_name) + + // Check if collection directory exists + if !os.exists(collection_dir) { + return c.error_collection_not_found(collection_name: collection_name) + } + + // Get all image files + mut image_names := []string{} + entries := os.ls(collection_dir)! + + for entry in entries { + entry_path := os.join_path(collection_dir, entry) + + // Skip directories + if os.is_dir(entry_path) { + continue + } + + // Check if it's an image + for ext in image_extensions { + if entry.ends_with(ext) { + image_names << entry + break + } + } + } + + return image_names +} + +// list_pages_map returns a map of collection names to a list of page names within that collection. +// The structure is map[collectionname][]pagename. +pub fn (mut c AtlasClient) list_pages_map() !map[string][]string { + mut result := map[string][]string{} + collections := c.list_collections()! + + for col_name in collections { + mut page_names := c.list_pages(col_name)! + page_names.sort() + result[col_name] = page_names + } + return result +} + +// list_markdown returns the collections and their pages in markdown format. +pub fn (mut c AtlasClient) list_markdown() !string { + mut markdown_output := '' + pages_map := c.list_pages_map()! + + if pages_map.len == 0 { + return 'No collections or pages found in this atlas export.' + } + + mut sorted_collections := pages_map.keys() + sorted_collections.sort() + + for col_name in sorted_collections { + page_names := pages_map[col_name] + markdown_output += '## ${col_name}\n' + if page_names.len == 0 { + markdown_output += ' * No pages in this collection.\n' + } else { + for page_name in page_names { + markdown_output += ' * ${page_name}\n' + } + } + markdown_output += '\n' // Add a newline for spacing between collections + } + return markdown_output +} + +// get_collection_metadata reads and parses the metadata JSON file for a collection +// Metadata is stored in {export_dir}/meta/{collection}.json +pub fn (mut c AtlasClient) get_collection_metadata(collection_name string) !CollectionMetadata { + // Apply name normalization + fixed_collection_name := texttools.name_fix_no_underscore_no_ext(collection_name) + + meta_path := os.join_path(c.export_dir, 'meta', '${fixed_collection_name}.json') + + // Check if metadata file exists + if !os.exists(meta_path) { + return c.error_collection_not_found_at( + collection_name: collection_name + path: meta_path + ) + } + + // Read and parse the JSON file + content := os.read_file(meta_path)! + metadata := json.decode(CollectionMetadata, content)! + + return metadata +} + +// get_page_links returns the links found in a page by reading the metadata +pub fn (mut c AtlasClient) get_page_links(collection_name string, page_name string) ![]LinkMetadata { + // Get collection metadata + metadata := c.get_collection_metadata(collection_name)! + + // Apply name normalization to page name + fixed_page_name := texttools.name_fix_no_underscore_no_ext(page_name) + + // Find the page in metadata + if fixed_page_name in metadata.pages { + return metadata.pages[fixed_page_name].links + } + + return c.error_page_not_found_in_metadata( + collection_name: collection_name + page_name: page_name + ) +} + +// get_collection_errors returns the errors for a collection from metadata +pub fn (mut c AtlasClient) get_collection_errors(collection_name string) ![]ErrorMetadata { + metadata := c.get_collection_metadata(collection_name)! + return metadata.errors +} + +// has_errors checks if a collection has any errors +pub fn (mut c AtlasClient) has_errors(collection_name string) bool { + errors := c.get_collection_errors(collection_name) or { return false } + return errors.len > 0 +} + +// copy_images copies all images linked in a page to a destination directory +// This is compatible with the doctreeclient API +pub fn (mut c AtlasClient) copy_images(collection_name string, page_name string, destination_path string) ! { + // Get page content + page_content := c.get_page_content(collection_name, page_name)! + + // Extract image links from content + image_names := extract_image_links(page_content, true)! + + // Ensure the destination directory exists + os.mkdir_all(destination_path)! + + // Create an 'img' subdirectory within the destination + images_dest_path := os.join_path(destination_path, 'img') + os.mkdir_all(images_dest_path)! + + // Copy each linked image + for image_name in image_names { + // Get the image path + image_path := c.get_image_path(collection_name, image_name) or { + // If an image is not found, return an error + return c.error_image_not_found_linked( + collection_name: collection_name + image_name: image_name + ) + } + + image_file_name := os.base(image_path) + dest_image_path := os.join_path(images_dest_path, image_file_name) + os.cp(image_path, dest_image_path)! + } +} diff --git a/lib/web/atlas_client/client_test.v b/lib/web/atlas_client/client_test.v new file mode 100644 index 00000000..b950723a --- /dev/null +++ b/lib/web/atlas_client/client_test.v @@ -0,0 +1,670 @@ +module atlas_client + +import os +import incubaid.herolib.core.texttools { name_fix_no_underscore_no_ext } + +// Helper function to create a test export directory structure +fn setup_test_export() string { + test_dir := os.join_path(os.temp_dir(), 'atlas_client_test_${os.getpid()}') + + // Clean up if exists + if os.exists(test_dir) { + os.rmdir_all(test_dir) or {} + } + + // Create directory structure + os.mkdir_all(os.join_path(test_dir, 'content', 'testcollection')) or { panic(err) } + os.mkdir_all(os.join_path(test_dir, 'content', 'anothercollection')) or { panic(err) } + os.mkdir_all(os.join_path(test_dir, 'meta')) or { panic(err) } + + // Create test pages + os.write_file(os.join_path(test_dir, 'content', 'testcollection', 'page1.md'), '# Page 1\n\nContent here.') or { + panic(err) + } + os.write_file(os.join_path(test_dir, 'content', 'testcollection', 'page2.md'), '# Page 2\n\n![logo](logo.png)') or { + panic(err) + } + os.write_file(os.join_path(test_dir, 'content', 'anothercollection', 'intro.md'), + '# Intro\n\nWelcome!') or { panic(err) } + + // Create test images + os.write_file(os.join_path(test_dir, 'content', 'testcollection', 'logo.png'), 'fake png data') or { + panic(err) + } + os.write_file(os.join_path(test_dir, 'content', 'testcollection', 'banner.jpg'), 'fake jpg data') or { + panic(err) + } + + // Create test files + os.write_file(os.join_path(test_dir, 'content', 'testcollection', 'data.csv'), 'col1,col2\nval1,val2') or { + panic(err) + } + + // Create metadata files + metadata1 := '{ + "name": "testcollection", + "path": "", + "pages": { + "page1": { + "name": "page1", + "path": "", + "collection_name": "testcollection", + "links": [] + }, + "page2": { + "name": "page2", + "path": "", + "collection_name": "testcollection", + "links": [ + { + "src": "logo.png", + "text": "logo", + "target": "logo.png", + "line": 3, + "target_collection_name": "testcollection", + "target_item_name": "logo", + "status": "ok", + "is_file_link": false, + "is_image_link": true + } + ] + } + }, + "files": {}, + "errors": [] +}' + os.write_file(os.join_path(test_dir, 'meta', 'testcollection.json'), metadata1) or { + panic(err) + } + + metadata2 := '{ + "name": "anothercollection", + "path": "", + "pages": { + "intro": { + "name": "intro", + "path": "", + "collection_name": "anothercollection", + "links": [] + } + }, + "files": {}, + "errors": [ + { + "category": "test", + "page_key": "intro", + "message": "Test error", + "line": 10 + } + ] +}' + os.write_file(os.join_path(test_dir, 'meta', 'anothercollection.json'), metadata2) or { + panic(err) + } + + return test_dir +} + +// Helper function to cleanup test directory +fn cleanup_test_export(test_dir string) { + os.rmdir_all(test_dir) or {} +} + +// Test creating a new client +fn test_new_client() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + assert client.export_dir == test_dir +} + +// Test creating client with non-existent directory +fn test_new_client_nonexistent_dir() { + mut client := new(export_dir: '/nonexistent/path/to/export') or { panic(err) } + // Client creation should succeed, but operations will fail + assert client.export_dir == '/nonexistent/path/to/export' +} + +// Test get_page_path - success +fn test_get_page_path_success() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + path := client.get_page_path('testcollection', 'page1') or { panic(err) } + + assert path.contains('testcollection') + assert path.ends_with('page1.md') + assert os.exists(path) +} + +// Test get_page_path - with naming normalization +fn test_get_page_path_normalization() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + // Create a page with normalized name + normalized_name := name_fix_no_underscore_no_ext('Test_Page-Name') + os.write_file(os.join_path(test_dir, 'content', 'testcollection', '${normalized_name}.md'), + '# Test') or { panic(err) } + + mut client := new(export_dir: test_dir) or { panic(err) } + + // Should find the page regardless of input format + path := client.get_page_path('testcollection', 'Test_Page-Name') or { panic(err) } + assert os.exists(path) +} + +// Test get_page_path - page not found +fn test_get_page_path_not_found() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + client.get_page_path('testcollection', 'nonexistent') or { + assert err.msg().contains('page_not_found') + assert err.msg().contains('nonexistent') + return + } + assert false, 'Should have returned an error' +} + +// Test get_page_path - export dir not found +fn test_get_page_path_no_export_dir() { + mut client := new(export_dir: '/nonexistent/path') or { panic(err) } + client.get_page_path('testcollection', 'page1') or { + assert err.msg().contains('export_dir_not_found') + return + } + assert false, 'Should have returned an error' +} + +// Test get_file_path - success +fn test_get_file_path_success() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + path := client.get_file_path('testcollection', 'data.csv') or { panic(err) } + + assert path.contains('testcollection') + assert path.ends_with('data.csv') + assert os.exists(path) +} + +// Test get_file_path - file not found +fn test_get_file_path_not_found() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + client.get_file_path('testcollection', 'missing.pdf') or { + assert err.msg().contains('file_not_found') + assert err.msg().contains('missing.pdf') + return + } + assert false, 'Should have returned an error' +} + +// Test get_image_path - success +fn test_get_image_path_success() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + path := client.get_image_path('testcollection', 'logo.png') or { panic(err) } + + assert path.contains('testcollection') + assert path.ends_with('logo.png') + assert os.exists(path) +} + +// Test get_image_path - image not found +fn test_get_image_path_not_found() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + client.get_image_path('testcollection', 'missing.jpg') or { + assert err.msg().contains('image_not_found') + assert err.msg().contains('missing.jpg') + return + } + assert false, 'Should have returned an error' +} + +// Test page_exists - true +fn test_page_exists_true() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + exists := client.page_exists('testcollection', 'page1') + assert exists == true +} + +// Test page_exists - false +fn test_page_exists_false() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + exists := client.page_exists('testcollection', 'nonexistent') + assert exists == false +} + +// Test file_exists - true +fn test_file_exists_true() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + exists := client.file_exists('testcollection', 'data.csv') + assert exists == true +} + +// Test file_exists - false +fn test_file_exists_false() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + exists := client.file_exists('testcollection', 'missing.pdf') + assert exists == false +} + +// Test image_exists - true +fn test_image_exists_true() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + exists := client.image_exists('testcollection', 'logo.png') + assert exists == true +} + +// Test image_exists - false +fn test_image_exists_false() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + exists := client.image_exists('testcollection', 'missing.svg') + assert exists == false +} + +// Test get_page_content - success +fn test_get_page_content_success() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + content := client.get_page_content('testcollection', 'page1') or { panic(err) } + + assert content.contains('# Page 1') + assert content.contains('Content here.') +} + +// Test get_page_content - page not found +fn test_get_page_content_not_found() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + client.get_page_content('testcollection', 'nonexistent') or { + assert err.msg().contains('page_not_found') + return + } + assert false, 'Should have returned an error' +} + +// Test list_collections +fn test_list_collections() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + collections := client.list_collections() or { panic(err) } + + assert collections.len == 2 + assert 'testcollection' in collections + assert 'anothercollection' in collections +} + +// Test list_collections - no content dir +fn test_list_collections_no_content_dir() { + test_dir := os.join_path(os.temp_dir(), 'empty_export_${os.getpid()}') + os.mkdir_all(test_dir) or { panic(err) } + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + client.list_collections() or { + assert err.msg().contains('invalid_export_structure') + return + } + assert false, 'Should have returned an error' +} + +// Test list_pages - success +fn test_list_pages_success() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + pages := client.list_pages('testcollection') or { panic(err) } + + assert pages.len == 2 + assert 'page1' in pages + assert 'page2' in pages +} + +// Test list_pages - collection not found +fn test_list_pages_collection_not_found() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + client.list_pages('nonexistent') or { + assert err.msg().contains('collection_not_found') + return + } + assert false, 'Should have returned an error' +} + +// Test list_files - success +fn test_list_files_success() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + files := client.list_files('testcollection') or { panic(err) } + + assert files.len == 1 + assert 'data.csv' in files +} + +// Test list_files - no files +fn test_list_files_empty() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + files := client.list_files('anothercollection') or { panic(err) } + + assert files.len == 0 +} + +// Test list_images - success +fn test_list_images_success() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + images := client.list_images('testcollection') or { panic(err) } + + assert images.len == 2 + assert 'logo.png' in images + assert 'banner.jpg' in images +} + +// Test list_images - no images +fn test_list_images_empty() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + images := client.list_images('anothercollection') or { panic(err) } + + assert images.len == 0 +} + +// Test list_pages_map +fn test_list_pages_map() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + pages_map := client.list_pages_map() or { panic(err) } + + assert pages_map.len == 2 + assert 'testcollection' in pages_map + assert 'anothercollection' in pages_map + assert pages_map['testcollection'].len == 2 + assert pages_map['anothercollection'].len == 1 +} + +// Test list_markdown +fn test_list_markdown() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + markdown := client.list_markdown() or { panic(err) } + + assert markdown.contains('testcollection') + assert markdown.contains('anothercollection') + assert markdown.contains('page1') + assert markdown.contains('page2') + assert markdown.contains('intro') + assert markdown.contains('##') + assert markdown.contains('*') +} + +// Test get_collection_metadata - success +fn test_get_collection_metadata_success() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + metadata := client.get_collection_metadata('testcollection') or { panic(err) } + + assert metadata.name == 'testcollection' + assert metadata.pages.len == 2 + assert metadata.errors.len == 0 +} + +// Test get_collection_metadata - with errors +fn test_get_collection_metadata_with_errors() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + metadata := client.get_collection_metadata('anothercollection') or { panic(err) } + + assert metadata.name == 'anothercollection' + assert metadata.pages.len == 1 + assert metadata.errors.len == 1 + assert metadata.errors[0].message == 'Test error' + assert metadata.errors[0].line == 10 +} + +// Test get_collection_metadata - not found +fn test_get_collection_metadata_not_found() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + client.get_collection_metadata('nonexistent') or { + assert err.msg().contains('collection_not_found') + return + } + assert false, 'Should have returned an error' +} + +// Test get_page_links - success +fn test_get_page_links_success() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + links := client.get_page_links('testcollection', 'page2') or { panic(err) } + + assert links.len == 1 + assert links[0].target_item_name == 'logo' + assert links[0].target_collection_name == 'testcollection' + assert links[0].is_image_link == true +} + +// Test get_page_links - no links +fn test_get_page_links_empty() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + links := client.get_page_links('testcollection', 'page1') or { panic(err) } + + assert links.len == 0 +} + +// Test get_page_links - page not found +fn test_get_page_links_page_not_found() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + client.get_page_links('testcollection', 'nonexistent') or { + assert err.msg().contains('page_not_found') + return + } + assert false, 'Should have returned an error' +} + +// Test get_collection_errors - success +fn test_get_collection_errors_success() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + errors := client.get_collection_errors('anothercollection') or { panic(err) } + + assert errors.len == 1 + assert errors[0].message == 'Test error' +} + +// Test get_collection_errors - no errors +fn test_get_collection_errors_empty() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + errors := client.get_collection_errors('testcollection') or { panic(err) } + + assert errors.len == 0 +} + +// Test has_errors - true +fn test_has_errors_true() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + has_errors := client.has_errors('anothercollection') + + assert has_errors == true +} + +// Test has_errors - false +fn test_has_errors_false() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + has_errors := client.has_errors('testcollection') + + assert has_errors == false +} + +// Test has_errors - collection not found +fn test_has_errors_collection_not_found() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + has_errors := client.has_errors('nonexistent') + + assert has_errors == false +} + +// Test copy_images - success +fn test_copy_images_success() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + dest_dir := os.join_path(os.temp_dir(), 'copy_dest_${os.getpid()}') + os.mkdir_all(dest_dir) or { panic(err) } + defer { cleanup_test_export(dest_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + client.copy_images('testcollection', 'page2', dest_dir) or { panic(err) } + + // Check that logo.png was copied to img subdirectory + assert os.exists(os.join_path(dest_dir, 'img', 'logo.png')) +} + +// Test copy_images - no images +fn test_copy_images_no_images() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + dest_dir := os.join_path(os.temp_dir(), 'copy_dest_empty_${os.getpid()}') + os.mkdir_all(dest_dir) or { panic(err) } + defer { cleanup_test_export(dest_dir) } + + mut client := new(export_dir: test_dir) or { panic(err) } + client.copy_images('testcollection', 'page1', dest_dir) or { panic(err) } + + // Should succeed even with no images + assert true +} + +// Test naming normalization edge cases +fn test_naming_normalization_underscores() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + // Create page with underscores + normalized := name_fix_no_underscore_no_ext('test_page_name') + os.write_file(os.join_path(test_dir, 'content', 'testcollection', '${normalized}.md'), + '# Test') or { panic(err) } + + mut client := new(export_dir: test_dir) or { panic(err) } + + // Should find with underscores + exists := client.page_exists('testcollection', 'test_page_name') + assert exists == true +} + +// Test naming normalization edge cases - dashes +fn test_naming_normalization_dashes() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + // Create page with dashes + normalized := name_fix_no_underscore_no_ext('test-page-name') + os.write_file(os.join_path(test_dir, 'content', 'testcollection', '${normalized}.md'), + '# Test') or { panic(err) } + + mut client := new(export_dir: test_dir) or { panic(err) } + + // Should find with dashes + exists := client.page_exists('testcollection', 'test-page-name') + assert exists == true +} + +// Test naming normalization edge cases - mixed case +fn test_naming_normalization_case() { + test_dir := setup_test_export() + defer { cleanup_test_export(test_dir) } + + // Create page with mixed case + normalized := name_fix_no_underscore_no_ext('TestPageName') + os.write_file(os.join_path(test_dir, 'content', 'testcollection', '${normalized}.md'), + '# Test') or { panic(err) } + + mut client := new(export_dir: test_dir) or { panic(err) } + + // Should find with mixed case + exists := client.page_exists('testcollection', 'TestPageName') + assert exists == true +} diff --git a/lib/web/atlas_client/error.v b/lib/web/atlas_client/error.v new file mode 100644 index 00000000..c3c2b399 --- /dev/null +++ b/lib/web/atlas_client/error.v @@ -0,0 +1,169 @@ +module atlas_client + +// AtlasErrors represents different types of errors that can occur in AtlasClient +pub enum AtlasErrors { + collection_not_found + page_not_found + file_not_found + image_not_found + export_dir_not_found + invalid_export_structure +} + +// AtlasError represents an error with a message and a reason +struct AtlasError { +pub mut: + message string // The error message + reason AtlasErrors // The reason for the error +} + +@[params] +struct ErrorArgs { +pub mut: + message string @[required] // The error message + reason AtlasErrors @[required] // The error reason +} + +// new_error creates a new AtlasError +pub fn new_error(args ErrorArgs) AtlasError { + return AtlasError{ + message: args.message + reason: args.reason + } +} + +// throw_error throws an error with a message and a reason +fn (err AtlasError) throw_error(args ErrorArgs) IError { + return error('${args.reason}: ${args.message}') +} + +// Error helper methods following the same pattern + +@[params] +struct CollectionNotFoundArgs { +pub mut: + collection_name string @[required] // The collection name +} + +// error_collection_not_found returns an error for when a collection is not found +pub fn (err AtlasError) error_collection_not_found(args CollectionNotFoundArgs) IError { + return err.throw_error( + message: 'Collection "${args.collection_name}" not found' + reason: .collection_not_found + ) +} + +@[params] +struct CollectionNotFoundAtArgs { +pub mut: + collection_name string @[required] // The collection name + path string @[required] // The path where metadata was expected +} + +// error_collection_not_found_at returns an error for when a collection metadata file is not found +pub fn (err AtlasError) error_collection_not_found_at(args CollectionNotFoundAtArgs) IError { + return err.throw_error( + message: 'Metadata file for collection "${args.collection_name}" not found at "${args.path}"' + reason: .collection_not_found + ) +} + +@[params] +struct PageNotFoundArgs { +pub mut: + collection_name string @[required] // The collection name + page_name string @[required] // The page name +} + +// error_page_not_found returns an error for when a page is not found in a collection +pub fn (err AtlasError) error_page_not_found(args PageNotFoundArgs) IError { + return err.throw_error( + message: 'Page "${args.page_name}" not found in collection "${args.collection_name}"' + reason: .page_not_found + ) +} + +// error_page_not_found_in_metadata returns an error for when a page is not found in collection metadata +pub fn (err AtlasError) error_page_not_found_in_metadata(args PageNotFoundArgs) IError { + return err.throw_error( + message: 'Page "${args.page_name}" not found in collection metadata' + reason: .page_not_found + ) +} + +@[params] +struct PageFileNotExistsArgs { +pub mut: + page_path string @[required] // The page file path +} + +// error_page_file_not_exists returns an error for when a page file doesn't exist on disk +pub fn (err AtlasError) error_page_file_not_exists(args PageFileNotExistsArgs) IError { + return err.throw_error( + message: 'Page file "${args.page_path}" does not exist on disk' + reason: .page_not_found + ) +} + +@[params] +struct FileNotFoundArgs { +pub mut: + collection_name string @[required] // The collection name + file_name string @[required] // The file name +} + +// error_file_not_found returns an error for when a file is not found in a collection +pub fn (err AtlasError) error_file_not_found(args FileNotFoundArgs) IError { + return err.throw_error( + message: 'File "${args.file_name}" not found in collection "${args.collection_name}"' + reason: .file_not_found + ) +} + +@[params] +struct ImageNotFoundArgs { +pub mut: + collection_name string @[required] // The collection name + image_name string @[required] // The image name +} + +// error_image_not_found returns an error for when an image is not found in a collection +pub fn (err AtlasError) error_image_not_found(args ImageNotFoundArgs) IError { + return err.throw_error( + message: 'Image "${args.image_name}" not found in collection "${args.collection_name}"' + reason: .image_not_found + ) +} + +// error_image_not_found_linked returns an error for when a linked image is not found +pub fn (err AtlasError) error_image_not_found_linked(args ImageNotFoundArgs) IError { + return error('Error: Linked image "${args.image_name}" not found in collection "${args.collection_name}".') +} + +@[params] +struct ExportDirNotFoundArgs { +pub mut: + export_dir string @[required] // The export directory path +} + +// error_export_dir_not_found returns an error for when the export directory doesn't exist +pub fn (err AtlasError) error_export_dir_not_found(args ExportDirNotFoundArgs) IError { + return err.throw_error( + message: 'Export directory "${args.export_dir}" not found' + reason: .export_dir_not_found + ) +} + +@[params] +struct InvalidExportStructureArgs { +pub mut: + content_dir string @[required] // The content directory path +} + +// error_invalid_export_structure returns an error for when the export directory structure is invalid +pub fn (err AtlasError) error_invalid_export_structure(args InvalidExportStructureArgs) IError { + return err.throw_error( + message: 'Content directory not found at "${args.content_dir}"' + reason: .invalid_export_structure + ) +} diff --git a/lib/web/atlas_client/error_test.v b/lib/web/atlas_client/error_test.v new file mode 100644 index 00000000..f2f06b1b --- /dev/null +++ b/lib/web/atlas_client/error_test.v @@ -0,0 +1,268 @@ +module atlas_client + +// Test error_collection_not_found +fn test_error_collection_not_found() { + err_handler := AtlasError{} + result := err_handler.error_collection_not_found(collection_name: 'test_collection') + + assert result.msg().contains('collection_not_found') + assert result.msg().contains('test_collection') + assert result.msg().contains('Collection') + assert result.msg().contains('not found') +} + +// Test error_collection_not_found with special characters +fn test_error_collection_not_found_special_chars() { + err_handler := AtlasError{} + result := err_handler.error_collection_not_found(collection_name: 'test-collection_123') + + assert result.msg().contains('test-collection_123') +} + +// Test error_collection_not_found with empty string +fn test_error_collection_not_found_empty() { + err_handler := AtlasError{} + result := err_handler.error_collection_not_found(collection_name: '') + + assert result.msg().contains('collection_not_found') +} + +// Test error_collection_not_found_at +fn test_error_collection_not_found_at() { + err_handler := AtlasError{} + result := err_handler.error_collection_not_found_at( + collection_name: 'my_collection' + path: '/tmp/meta/my_collection.json' + ) + + assert result.msg().contains('collection_not_found') + assert result.msg().contains('my_collection') + assert result.msg().contains('/tmp/meta/my_collection.json') + assert result.msg().contains('Metadata file') +} + +// Test error_page_not_found +fn test_error_page_not_found() { + err_handler := AtlasError{} + result := err_handler.error_page_not_found( + collection_name: 'docs' + page_name: 'intro' + ) + + assert result.msg().contains('page_not_found') + assert result.msg().contains('docs') + assert result.msg().contains('intro') + assert result.msg().contains('Page') +} + +// Test error_page_not_found with underscores and dashes +fn test_error_page_not_found_naming() { + err_handler := AtlasError{} + result := err_handler.error_page_not_found( + collection_name: 'my-docs_v2' + page_name: 'getting_started' + ) + + assert result.msg().contains('my-docs_v2') + assert result.msg().contains('getting_started') +} + +// Test error_page_not_found_in_metadata +fn test_error_page_not_found_in_metadata() { + err_handler := AtlasError{} + result := err_handler.error_page_not_found_in_metadata( + collection_name: 'api' + page_name: 'endpoints' + ) + + assert result.msg().contains('page_not_found') + assert result.msg().contains('endpoints') + assert result.msg().contains('metadata') +} + +// Test error_page_file_not_exists +fn test_error_page_file_not_exists() { + err_handler := AtlasError{} + result := err_handler.error_page_file_not_exists( + page_path: '/tmp/content/docs/page.md' + ) + + assert result.msg().contains('page_not_found') + assert result.msg().contains('/tmp/content/docs/page.md') + assert result.msg().contains('does not exist') +} + +// Test error_file_not_found +fn test_error_file_not_found() { + err_handler := AtlasError{} + result := err_handler.error_file_not_found( + collection_name: 'resources' + file_name: 'data.csv' + ) + + assert result.msg().contains('file_not_found') + assert result.msg().contains('resources') + assert result.msg().contains('data.csv') + assert result.msg().contains('File') +} + +// Test error_file_not_found with various extensions +fn test_error_file_not_found_extensions() { + err_handler := AtlasError{} + + // Test PDF + result1 := err_handler.error_file_not_found( + collection_name: 'docs' + file_name: 'manual.pdf' + ) + assert result1.msg().contains('manual.pdf') + + // Test JSON + result2 := err_handler.error_file_not_found( + collection_name: 'config' + file_name: 'settings.json' + ) + assert result2.msg().contains('settings.json') +} + +// Test error_image_not_found +fn test_error_image_not_found() { + err_handler := AtlasError{} + result := err_handler.error_image_not_found( + collection_name: 'gallery' + image_name: 'logo.png' + ) + + assert result.msg().contains('image_not_found') + assert result.msg().contains('gallery') + assert result.msg().contains('logo.png') + assert result.msg().contains('Image') +} + +// Test error_image_not_found with various image formats +fn test_error_image_not_found_formats() { + err_handler := AtlasError{} + + formats := ['logo.png', 'banner.jpg', 'icon.svg', 'photo.webp', 'diagram.gif'] + for format in formats { + result := err_handler.error_image_not_found( + collection_name: 'images' + image_name: format + ) + assert result.msg().contains(format) + } +} + +// Test error_image_not_found_linked +fn test_error_image_not_found_linked() { + err_handler := AtlasError{} + result := err_handler.error_image_not_found_linked( + collection_name: 'blog' + image_name: 'header.jpg' + ) + + assert result.msg().contains('Linked image') + assert result.msg().contains('blog') + assert result.msg().contains('header.jpg') +} + +// Test error_export_dir_not_found +fn test_error_export_dir_not_found() { + err_handler := AtlasError{} + result := err_handler.error_export_dir_not_found( + export_dir: '/nonexistent/path' + ) + + assert result.msg().contains('export_dir_not_found') + assert result.msg().contains('/nonexistent/path') + assert result.msg().contains('Export directory') +} + +// Test error_invalid_export_structure +fn test_error_invalid_export_structure() { + err_handler := AtlasError{} + result := err_handler.error_invalid_export_structure( + content_dir: '/tmp/export/content' + ) + + assert result.msg().contains('invalid_export_structure') + assert result.msg().contains('/tmp/export/content') + assert result.msg().contains('Content directory') +} + +// Test new_error function +fn test_new_error() { + err := new_error( + message: 'Test error message' + reason: .page_not_found + ) + + assert err.message == 'Test error message' + assert err.reason == .page_not_found +} + +// Test new_error with all error types +fn test_new_error_all_types() { + error_types := [ + AtlasErrors.collection_not_found, + AtlasErrors.page_not_found, + AtlasErrors.file_not_found, + AtlasErrors.image_not_found, + AtlasErrors.export_dir_not_found, + AtlasErrors.invalid_export_structure, + ] + + for error_type in error_types { + err := new_error( + message: 'Test message' + reason: error_type + ) + assert err.reason == error_type + } +} + +// Test throw_error internal method +fn test_throw_error() { + err_handler := AtlasError{} + result := err_handler.throw_error( + message: 'Custom error message' + reason: .file_not_found + ) + + assert result.msg().contains('file_not_found') + assert result.msg().contains('Custom error message') +} + +// Test error messages are properly formatted +fn test_error_message_format() { + err_handler := AtlasError{} + + // Test that error messages follow the pattern: "reason: message" + result := err_handler.error_page_not_found( + collection_name: 'test' + page_name: 'page' + ) + + msg := result.msg() + assert msg.contains(':') + + // Split by colon and verify format + parts := msg.split(':') + assert parts.len >= 2 +} + +// Test error consistency across similar methods +fn test_error_consistency() { + err_handler := AtlasError{} + + // All "not found" errors should contain "not found" in message + err1 := err_handler.error_collection_not_found(collection_name: 'test') + err2 := err_handler.error_page_not_found(collection_name: 'test', page_name: 'page') + err3 := err_handler.error_file_not_found(collection_name: 'test', file_name: 'file') + err4 := err_handler.error_image_not_found(collection_name: 'test', image_name: 'img') + + assert err1.msg().contains('not found') + assert err2.msg().contains('not found') + assert err3.msg().contains('not found') + assert err4.msg().contains('not found') +} diff --git a/lib/web/atlas_client/extract_links.v b/lib/web/atlas_client/extract_links.v new file mode 100644 index 00000000..15be626c --- /dev/null +++ b/lib/web/atlas_client/extract_links.v @@ -0,0 +1,55 @@ +module atlas_client + +import os + +// extract_image_links extracts image file names from markdown content +// If exclude_http is true, it will skip images with http:// or https:// URLs +pub fn extract_image_links(s string, exclude_http bool) ![]string { + mut result := []string{} + mut current_pos := 0 + for { + if current_pos >= s.len { + break + } + + // Find the start of an image markdown link + start_index := s.index_after('![', current_pos) or { -1 } + if start_index == -1 { + break // No more image links found + } + + // Find the closing bracket for alt text + alt_end_index := s.index_after(']', start_index) or { -1 } + if alt_end_index == -1 { + break + } + + // Check for opening parenthesis for URL + if alt_end_index + 1 >= s.len || s[alt_end_index + 1] != `(` { + current_pos = alt_end_index + 1 // Move past this invalid sequence + continue + } + + // Find the closing parenthesis for URL + url_start_index := alt_end_index + 2 + url_end_index := s.index_after(')', url_start_index) or { -1 } + if url_end_index == -1 { + break + } + + // Extract the URL + url := s[url_start_index..url_end_index] + if exclude_http && (url.starts_with('http://') || url.starts_with('https://')) { + current_pos = url_end_index + 1 + continue + } + + // Extract only the base name of the image from the URL + image_base_name := os.base(url) + result << image_base_name + + // Move current_pos past the found link to continue searching + current_pos = url_end_index + 1 + } + return result +} diff --git a/lib/web/atlas_client/extract_links_test.v b/lib/web/atlas_client/extract_links_test.v new file mode 100644 index 00000000..032e321f --- /dev/null +++ b/lib/web/atlas_client/extract_links_test.v @@ -0,0 +1,298 @@ +module atlas_client + +// Test basic image link extraction +fn test_extract_image_links_basic() { + content := '![alt text](image.png)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 1 + assert result[0] == 'image.png' +} + +// Test multiple image links +fn test_extract_image_links_multiple() { + content := '![logo](logo.png) some text ![banner](banner.jpg) more text ![icon](icon.svg)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 3 + assert result[0] == 'logo.png' + assert result[1] == 'banner.jpg' + assert result[2] == 'icon.svg' +} + +// Test empty content +fn test_extract_image_links_empty() { + content := '' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 0 +} + +// Test content with no images +fn test_extract_image_links_no_images() { + content := 'This is just plain text with no images' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 0 +} + +// Test content with regular links (not images) +fn test_extract_image_links_regular_links() { + content := '[regular link](page.md) and [another](doc.html)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 0 +} + +// Test HTTP URLs with exclude_http = true +fn test_extract_image_links_exclude_http() { + content := '![local](local.png) ![remote](http://example.com/image.jpg) ![https](https://example.com/logo.png)' + result := extract_image_links(content, true) or { panic(err) } + + assert result.len == 1 + assert result[0] == 'local.png' +} + +// Test HTTP URLs with exclude_http = false +fn test_extract_image_links_include_http() { + content := '![local](local.png) ![remote](http://example.com/image.jpg) ![https](https://example.com/logo.png)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 3 + assert result[0] == 'local.png' + assert result[1] == 'image.jpg' + assert result[2] == 'logo.png' +} + +// Test image paths with directories +fn test_extract_image_links_with_paths() { + content := '![img1](images/logo.png) ![img2](../assets/banner.jpg) ![img3](./icons/icon.svg)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 3 + assert result[0] == 'logo.png' + assert result[1] == 'banner.jpg' + assert result[2] == 'icon.svg' +} + +// Test various image formats +fn test_extract_image_links_formats() { + content := '![png](img.png) ![jpg](img.jpg) ![jpeg](img.jpeg) ![gif](img.gif) ![svg](img.svg) ![webp](img.webp) ![bmp](img.bmp)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 7 + assert 'img.png' in result + assert 'img.jpg' in result + assert 'img.jpeg' in result + assert 'img.gif' in result + assert 'img.svg' in result + assert 'img.webp' in result + assert 'img.bmp' in result +} + +// Test malformed markdown - missing closing bracket +fn test_extract_image_links_malformed_no_closing_bracket() { + content := '![alt text(image.png)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 0 +} + +// Test malformed markdown - missing opening parenthesis +fn test_extract_image_links_malformed_no_paren() { + content := '![alt text]image.png)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 0 +} + +// Test malformed markdown - missing closing parenthesis +fn test_extract_image_links_malformed_no_closing_paren() { + content := '![alt text](image.png' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 0 +} + +// Test empty alt text +fn test_extract_image_links_empty_alt() { + content := '![](image.png)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 1 + assert result[0] == 'image.png' +} + +// Test alt text with special characters +fn test_extract_image_links_special_alt() { + content := '![Logo & Banner - 2024!](logo.png)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 1 + assert result[0] == 'logo.png' +} + +// Test image names with special characters +fn test_extract_image_links_special_names() { + content := '![img1](logo-2024.png) ![img2](banner_v2.jpg) ![img3](icon.final.svg)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 3 + assert result[0] == 'logo-2024.png' + assert result[1] == 'banner_v2.jpg' + assert result[2] == 'icon.final.svg' +} + +// Test mixed content with text, links, and images +fn test_extract_image_links_mixed_content() { + content := ' +# Header + +Some text with [a link](page.md) and an image ![logo](logo.png). + +## Section + +More text and ![banner](images/banner.jpg) another image. + +[Another link](doc.html) + +![icon](icon.svg) +' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 3 + assert result[0] == 'logo.png' + assert result[1] == 'banner.jpg' + assert result[2] == 'icon.svg' +} + +// Test consecutive images +fn test_extract_image_links_consecutive() { + content := '![img1](a.png)![img2](b.jpg)![img3](c.svg)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 3 + assert result[0] == 'a.png' + assert result[1] == 'b.jpg' + assert result[2] == 'c.svg' +} + +// Test images with query parameters +fn test_extract_image_links_query_params() { + content := '![img](image.png?size=large&format=webp)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 1 + // Should extract the full filename including query params + assert result[0].contains('image.png') +} + +// Test images with anchors +fn test_extract_image_links_anchors() { + content := '![img](image.png#section)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 1 + assert result[0].contains('image.png') +} + +// Test duplicate images +fn test_extract_image_links_duplicates() { + content := '![img1](logo.png) some text ![img2](logo.png) more text ![img3](logo.png)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 3 + assert result[0] == 'logo.png' + assert result[1] == 'logo.png' + assert result[2] == 'logo.png' +} + +// Test very long content +fn test_extract_image_links_long_content() { + mut content := '' + for i in 0 .. 100 { + content += 'Some text here. ' + if i % 10 == 0 { + content += '![img${i}](image${i}.png) ' + } + } + + result := extract_image_links(content, false) or { panic(err) } + assert result.len == 10 +} + +// Test image with absolute path +fn test_extract_image_links_absolute_path() { + content := '![img](/absolute/path/to/image.png)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 1 + assert result[0] == 'image.png' +} + +// Test image with Windows-style path +fn test_extract_image_links_windows_path() { + content := '![img](C:\\Users\\images\\logo.png)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 1 + assert result[0] == 'logo.png' +} + +// Test nested brackets in alt text +fn test_extract_image_links_nested_brackets() { + content := '![alt [with] brackets](image.png)' + result := extract_image_links(content, false) or { panic(err) } + + // This might not work correctly due to nested brackets + // The function should handle it gracefully + assert result.len >= 0 +} + +// Test image link at start of string +fn test_extract_image_links_at_start() { + content := '![logo](logo.png) followed by text' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 1 + assert result[0] == 'logo.png' +} + +// Test image link at end of string +fn test_extract_image_links_at_end() { + content := 'text followed by ![logo](logo.png)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 1 + assert result[0] == 'logo.png' +} + +// Test only image link +fn test_extract_image_links_only() { + content := '![logo](logo.png)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 1 + assert result[0] == 'logo.png' +} + +// Test whitespace in URL +fn test_extract_image_links_whitespace() { + content := '![img]( image.png )' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 1 + // Should preserve whitespace as-is + assert result[0].contains('image.png') +} + +// Test case sensitivity +fn test_extract_image_links_case_sensitivity() { + content := '![img1](Image.PNG) ![img2](LOGO.jpg) ![img3](banner.SVG)' + result := extract_image_links(content, false) or { panic(err) } + + assert result.len == 3 + assert result[0] == 'Image.PNG' + assert result[1] == 'LOGO.jpg' + assert result[2] == 'banner.SVG' +} diff --git a/lib/web/atlas_client/factory.v b/lib/web/atlas_client/factory.v new file mode 100644 index 00000000..3165ddd3 --- /dev/null +++ b/lib/web/atlas_client/factory.v @@ -0,0 +1,22 @@ +module atlas_client + +import incubaid.herolib.core.base + +@[params] +pub struct AtlasClientArgs { +pub: + export_dir string @[required] // Path to atlas export directory +} + +// Create a new AtlasClient instance +// The export_dir should point to the directory containing content/ and meta/ subdirectories +pub fn new(args AtlasClientArgs) !&AtlasClient { + mut context := base.context()! + mut redis := context.redis()! + + return &AtlasClient{ + AtlasError: AtlasError{} + redis: redis + export_dir: args.export_dir + } +} diff --git a/lib/web/atlas_client/model.v b/lib/web/atlas_client/model.v new file mode 100644 index 00000000..062bb364 --- /dev/null +++ b/lib/web/atlas_client/model.v @@ -0,0 +1,12 @@ +module atlas_client + +import incubaid.herolib.core.redisclient + +// AtlasClient provides access to Atlas-exported documentation collections +// It reads from both the exported directory structure and Redis metadata +pub struct AtlasClient { + AtlasError // Embedded error handler for generating standardized errors +pub mut: + redis &redisclient.Redis + export_dir string // Path to the atlas export directory (contains content/ and meta/) +}