2026-04-14-1

This commit is contained in:
Hamza-Ayed
2026-04-14 03:03:35 +03:00
parent b36e197e09
commit 581eda1ea8
15 changed files with 102069 additions and 304 deletions
@@ -0,0 +1,304 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { NeighborhoodPoint } from './entities/neighborhood-point.entity';
import { NeighborhoodPolygon } from './entities/neighborhood-polygon.entity';
import { AdminBoundary } from './entities/admin-boundary.entity';
import { PlaceJordan } from './entities/place-jordan.entity';
import { PlaceSyria } from './entities/place-syria.entity';
import axios from 'axios';
@Injectable()
export class AdministrativeLinkingService {
private readonly logger = new Logger(AdministrativeLinkingService.name);
constructor(
private dataSource: DataSource,
@InjectRepository(NeighborhoodPoint)
private readonly neighborhoodPointRepo: Repository<NeighborhoodPoint>,
@InjectRepository(NeighborhoodPolygon)
private readonly neighborhoodPolygonRepo: Repository<NeighborhoodPolygon>,
@InjectRepository(AdminBoundary)
private readonly adminBoundaryRepo: Repository<AdminBoundary>,
) {}
private readonly overpassMirrors = [
'https://overpass-api.de/api/interpreter',
'https://overpass.kumi.systems/api/interpreter',
'https://z.overpass-api.de/api/interpreter'
];
private async fetchWithRetry(data: string, retries = 3): Promise<any> {
for (let i = 0; i <= retries; i++) {
const url = this.overpassMirrors[i % this.overpassMirrors.length];
try {
const response = await axios.post(url, data, {
timeout: 45000,
headers: {
'User-Agent': 'IntaleqMapBot/1.0',
'Content-Type': 'application/x-www-form-urlencoded'
}
});
return response.data;
} catch (error) {
this.logger.warn(`Mirror ${url} failed: ${error.message}. Trying next mirror...`);
if (i === retries) throw error;
await new Promise(res => setTimeout(res, 2000));
}
}
}
/**
* Step 1: Sync Neighborhood Points from OSM for a given BBox
*/
async syncOsmNeighborhoodPoints(bbox: string = '31.86,35.94,32.22,36.25') {
this.logger.log(`Syncing neighborhood points for bbox: ${bbox}`);
const query = `[out:json][timeout:900];node["place"~"neighbourhood|suburb|town"](${bbox});out;`;
try {
const data = await this.fetchWithRetry(`data=${encodeURIComponent(query)}`);
const elements = data.elements;
this.logger.log(`Found ${elements.length} points in OSM.`);
for (const e of elements) {
const nameAr = e.tags['name:ar'] || e.tags['name'];
const nameEn = e.tags['name:en'] || e.tags['name'];
const geometry = { type: 'Point', coordinates: [e.lon, e.lat] };
await this.dataSource.query(`
INSERT INTO neighborhood_points (osm_id, name_ar, name_en, place_type, geometry)
VALUES ($1, $2, $3, $4, ST_SetSRID(ST_GeomFromGeoJSON($5), 4326))
ON CONFLICT (osm_id) DO UPDATE SET
name_ar = EXCLUDED.name_ar,
name_en = EXCLUDED.name_en,
place_type = EXCLUDED.place_type,
geometry = EXCLUDED.geometry;
`, [e.id, nameAr, nameEn, e.tags['place'], JSON.stringify(geometry)]);
}
// Link points to parent districts using containment or nearest neighbor
await this.dataSource.query(`
UPDATE neighborhood_points np
SET district_id = COALESCE(
(
SELECT id FROM admin_boundaries ab
WHERE ab.admin_level IN (6, 8)
AND ST_Contains(ab.geom::geometry, np.geometry::geometry)
ORDER BY ab.admin_level DESC
LIMIT 1
),
(
SELECT id FROM admin_boundaries ab
WHERE ab.admin_level IN (6, 8)
ORDER BY ab.geom::geometry <-> np.geometry::geometry
LIMIT 1
)
)
WHERE district_id IS NULL;
`);
// Diagnostics
const totalPoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points`);
const linkedPoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points WHERE district_id IS NOT NULL`);
const unlinkedPoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points WHERE district_id IS NULL`);
const adminCount = await this.dataSource.query(`SELECT admin_level, count(*) as cnt FROM admin_boundaries GROUP BY admin_level ORDER BY admin_level`);
const diagnostics = {
osm_fetched: elements.length,
total_points_in_db: parseInt(totalPoints[0].cnt),
linked_to_district: parseInt(linkedPoints[0].cnt),
unlinked: parseInt(unlinkedPoints[0].cnt),
admin_boundaries: adminCount.map(r => ({ level: r.admin_level, count: parseInt(r.cnt) })),
};
this.logger.log(`Step 1 diagnostics: ${JSON.stringify(diagnostics)}`);
return diagnostics;
} catch (error) {
this.logger.error(`Failed to sync OSM points: ${error.message}`, error.stack);
throw error;
}
}
/**
* Step 2: Generate Voronoi Polygons for Neighborhoods
*/
async generateVoronoiNeighborhoods() {
this.logger.log('Generating Voronoi polygons for neighborhoods...');
// Pre-flight diagnostics
const prePoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points WHERE district_id IS NOT NULL`);
const preDistricts = await this.dataSource.query(`SELECT district_id, count(*) as cnt FROM neighborhood_points WHERE district_id IS NOT NULL GROUP BY district_id`);
this.logger.log(`Pre-flight: ${prePoints[0].cnt} points across ${preDistricts.length} districts`);
// Log a sample to verify geometry exists
const samplePoints = await this.dataSource.query(`SELECT osm_id, name_ar, district_id, ST_AsText(geometry) as geom_text FROM neighborhood_points WHERE district_id IS NOT NULL LIMIT 3`);
this.logger.log(`Sample points: ${JSON.stringify(samplePoints)}`);
const sampleDistricts = await this.dataSource.query(`SELECT id, name_ar, admin_level, ST_AsText(ST_Centroid(geom)) as centroid FROM admin_boundaries WHERE admin_level IN (6, 8) LIMIT 3`);
this.logger.log(`Sample districts: ${JSON.stringify(sampleDistricts)}`);
// Delete old voronoi polygons
await this.dataSource.query(`DELETE FROM neighborhood_polygons WHERE method = 'voronoi'`);
this.logger.log('Deleted old voronoi polygons.');
// Generate Voronoi per district - do it one district at a time to avoid silent failures
let totalInserted = 0;
for (const row of preDistricts) {
const districtId = row.district_id;
const pointCount = parseInt(row.cnt);
try {
if (pointCount >= 2) {
// Normal Voronoi for 2+ points
const result = await this.dataSource.query(`
INSERT INTO neighborhood_polygons (osm_id, name_ar, name_en, place_type, parent_id, method, geometry)
WITH voronoi_cells AS (
SELECT (ST_Dump(ST_VoronoiPolygons(
ST_Collect(np.geometry::geometry),
0.00001,
d.geom::geometry
))).geom as cell
FROM neighborhood_points np
JOIN admin_boundaries d ON d.id = np.district_id
WHERE np.district_id = $1
GROUP BY d.geom
),
matched AS (
SELECT
np.osm_id, np.name_ar, np.name_en, np.place_type, np.district_id,
ST_Multi(ST_Intersection(vc.cell::geometry, d.geom::geometry)) as geometry
FROM voronoi_cells vc
CROSS JOIN LATERAL (
SELECT np2.*
FROM neighborhood_points np2
WHERE np2.district_id = $1
ORDER BY np2.geometry::geometry <-> vc.cell::geometry
LIMIT 1
) np
JOIN admin_boundaries d ON d.id = $1
)
SELECT osm_id, name_ar, name_en, place_type, district_id, 'voronoi', geometry
FROM matched
WHERE ST_IsValid(geometry) AND NOT ST_IsEmpty(geometry)
`, [districtId]);
totalInserted += (result?.length || result?.[1] || 0);
this.logger.log(`District ${districtId}: ${pointCount} points -> ${result?.length || result?.[1] || '?'} polygons`);
} else if (pointCount === 1) {
// Single point: assign entire district boundary
const result = await this.dataSource.query(`
INSERT INTO neighborhood_polygons (osm_id, name_ar, name_en, place_type, parent_id, method, geometry)
SELECT np.osm_id, np.name_ar, np.name_en, np.place_type, np.district_id, 'voronoi', ST_Multi(d.geom::geometry)
FROM neighborhood_points np
JOIN admin_boundaries d ON d.id = np.district_id
WHERE np.district_id = $1
`, [districtId]);
totalInserted += (result?.length || result?.[1] || 0);
this.logger.log(`District ${districtId}: 1 point -> assigned district boundary`);
}
} catch (err) {
this.logger.error(`Voronoi FAILED for district ${districtId} (${pointCount} points): ${err.message}`);
}
}
// Post-flight count
const postCount = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_polygons`);
const finalCount = parseInt(postCount[0].cnt);
this.logger.log(`Step 2 complete: ${finalCount} total polygons in neighborhood_polygons`);
// Sample polygon names
const samplePolygons = await this.dataSource.query(`SELECT id, osm_id, name_ar, parent_id, method FROM neighborhood_polygons LIMIT 5`);
this.logger.log(`Sample polygons: ${JSON.stringify(samplePolygons)}`);
return {
status: finalCount > 0 ? 'success' : 'WARNING_EMPTY',
total_polygons: finalCount,
districts_processed: preDistricts.length,
sample: samplePolygons
};
}
/**
* Step 3: Link all Places (Jordan & Syria) to the full administrative hierarchy
*/
async linkPlaces(country: 'jordan' | 'syria') {
const tableName = country === 'jordan' ? 'places_jordan' : 'places_syria';
// Pre-flight diagnostics
const polyCount = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_polygons`);
const placeCount = await this.dataSource.query(`SELECT count(*) as cnt FROM ${tableName} WHERE location IS NOT NULL`);
this.logger.log(`linkPlaces pre-flight: ${polyCount[0].cnt} polygons, ${placeCount[0].cnt} places with location in ${tableName}`);
if (parseInt(polyCount[0].cnt) === 0) {
this.logger.error('ABORT: neighborhood_polygons is EMPTY. Run generate-voronoi first!');
return {
status: 'FAILED',
error: 'neighborhood_polygons table is empty. Run generate-voronoi first.',
polygons: 0,
places: parseInt(placeCount[0].cnt)
};
}
this.logger.log(`Linking administrative hierarchy for ${tableName} using KNN...`);
// Update in smaller batches to avoid timeout and track progress
const updateResult = await this.dataSource.query(`
UPDATE ${tableName} p
SET
neighborhood_id = (
SELECT np.id FROM neighborhood_polygons np
ORDER BY p.location::geometry <-> np.geometry::geometry
LIMIT 1
),
sub_district_id = (
SELECT ab.id FROM admin_boundaries ab
WHERE ab.admin_level = 8
AND ST_Within(p.location::geometry, ab.geom::geometry)
LIMIT 1
),
district_id = (
SELECT ab.id FROM admin_boundaries ab
WHERE ab.admin_level = 6
AND ST_Within(p.location::geometry, ab.geom::geometry)
LIMIT 1
),
governorate_id = (
SELECT ab.id FROM admin_boundaries ab
WHERE ab.admin_level = 4
AND ST_Within(p.location::geometry, ab.geom::geometry)
LIMIT 1
)
WHERE p.location IS NOT NULL;
`);
// Post-flight diagnostics
const linkedNeighborhood = await this.dataSource.query(`SELECT count(*) as cnt FROM ${tableName} WHERE neighborhood_id IS NOT NULL`);
const linkedDistrict = await this.dataSource.query(`SELECT count(*) as cnt FROM ${tableName} WHERE district_id IS NOT NULL`);
const linkedGov = await this.dataSource.query(`SELECT count(*) as cnt FROM ${tableName} WHERE governorate_id IS NOT NULL`);
const totalPlaces = await this.dataSource.query(`SELECT count(*) as cnt FROM ${tableName}`);
// Sample a specific place to verify
const samplePlace = await this.dataSource.query(`
SELECT p.id, p.name_ar, p.neighborhood_id, p.district_id, p.governorate_id,
n.name_ar as neighborhood_name
FROM ${tableName} p
LEFT JOIN neighborhood_polygons n ON p.neighborhood_id = n.id
WHERE p.name_ar LIKE '%معصوم%'
LIMIT 3
`);
const diagnostics = {
status: 'completed',
country,
total_places: parseInt(totalPlaces[0].cnt),
linked_neighborhood: parseInt(linkedNeighborhood[0].cnt),
linked_district: parseInt(linkedDistrict[0].cnt),
linked_governorate: parseInt(linkedGov[0].cnt),
sample_masoum: samplePlace,
rows_affected: updateResult?.[1] || 'unknown'
};
this.logger.log(`Step 3 diagnostics: ${JSON.stringify(diagnostics)}`);
return diagnostics;
}
}
@@ -48,17 +48,17 @@ export abstract class BasePlace {
@Column({ type: 'int', nullable: true })
@Index()
admin_level4_id: number;
governorate_id: number;
@Column({ type: 'int', nullable: true })
@Index()
admin_level6_id: number;
district_id: number;
@Column({ type: 'int', nullable: true })
@Index()
admin_level8_id: number;
sub_district_id: number;
@Column({ type: 'int', nullable: true })
@Index()
admin_level10_id: number;
neighborhood_id: number;
}
@@ -0,0 +1,29 @@
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
@Entity('neighborhood_points')
export class NeighborhoodPoint {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'bigint', unique: true, nullable: true })
@Index()
osm_id: number;
@Column({ nullable: true })
@Index()
name_ar: string;
@Column({ nullable: true })
name_en: string;
@Column({ nullable: true })
place_type: string;
@Column({ type: 'int', nullable: true })
@Index()
district_id: number;
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326 })
@Index({ spatial: true })
geometry: any;
}
@@ -0,0 +1,35 @@
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
@Entity('neighborhood_polygons')
export class NeighborhoodPolygon {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'bigint', nullable: true })
@Index()
osm_id: number;
@Column({ nullable: true })
@Index()
name_ar: string;
@Column({ nullable: true })
name_en: string;
@Column({ nullable: true })
place_type: string;
@Column({ type: 'int', nullable: true })
@Index()
parent_id: number; // Links to admin_boundaries (District/Sub-district)
@Column({ default: 'voronoi' })
method: string;
@Column({ type: 'float', default: 0.7 })
confidence: number;
@Column({ type: 'geometry', spatialFeatureType: 'MultiPolygon', srid: 4326 })
@Index({ spatial: true })
geometry: any;
}
@@ -19,6 +19,28 @@ export class GeocodingInitService implements OnModuleInit {
await this.repo.query('CREATE EXTENSION IF NOT EXISTS postgis;');
await this.repo.query('CREATE EXTENSION IF NOT EXISTS pg_trgm;');
// Migration: Rename old admin_level columns to descriptive names if they exist
const tables = ['places_jordan', 'places_syria', 'places_egypt'];
const columnMapping = [
{ old: 'admin_level4_id', new: 'governorate_id' },
{ old: 'admin_level6_id', new: 'district_id' },
{ old: 'admin_level8_id', new: 'sub_district_id' },
{ old: 'admin_level10_id', new: 'neighborhood_id' }
];
for (const table of tables) {
for (const mapping of columnMapping) {
await this.repo.query(`
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = '${table}' AND column_name = '${mapping.old}') THEN
ALTER TABLE ${table} RENAME COLUMN ${mapping.old} TO ${mapping.new};
END IF;
END $$;
`);
}
}
// Cleanup: Create a trigger to automatically update 'location' when lat/lng changes
await this.repo.query(`
CREATE OR REPLACE FUNCTION sync_place_location() RETURNS trigger AS $$
@@ -26,10 +48,13 @@ export class GeocodingInitService implements OnModuleInit {
IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL THEN
NEW.location := ST_SetSRID(ST_MakePoint(CAST(NEW.longitude AS FLOAT), CAST(NEW.latitude AS FLOAT)), 4326);
NEW.admin_level4_id := (SELECT id FROM admin_boundaries WHERE admin_level = 4 AND ST_Contains(geom, NEW.location) LIMIT 1);
NEW.admin_level6_id := (SELECT id FROM admin_boundaries WHERE admin_level = 6 AND ST_Contains(geom, NEW.location) LIMIT 1);
NEW.admin_level8_id := (SELECT id FROM admin_boundaries WHERE admin_level = 8 AND ST_Contains(geom, NEW.location) LIMIT 1);
NEW.admin_level10_id := (SELECT id FROM admin_boundaries WHERE admin_level = 10 AND ST_Contains(geom, NEW.location) LIMIT 1);
-- Keep standard admin boundary logic for higher levels
NEW.governorate_id := (SELECT id FROM admin_boundaries WHERE admin_level = 4 AND ST_Contains(geom, NEW.location) LIMIT 1);
NEW.district_id := (SELECT id FROM admin_boundaries WHERE admin_level = 6 AND ST_Contains(geom, NEW.location) LIMIT 1);
NEW.sub_district_id := (SELECT id FROM admin_boundaries WHERE admin_level = 8 AND ST_Contains(geom, NEW.location) LIMIT 1);
-- ROOT CAUSE FIX: Use the new Voronoi polygons for neighborhoods!
NEW.neighborhood_id := (SELECT id FROM neighborhood_polygons ORDER BY NEW.location::geometry <-> geometry::geometry LIMIT 1);
END IF;
RETURN NEW;
END;
@@ -2,6 +2,8 @@ import { Controller, Get, Post, Delete, Body, Query, UseGuards, HttpException, H
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { GeocodingService } from './geocoding.service';
import { AdminBoundariesService } from './admin-boundaries.service';
import { JordanResearchService } from './jordan-research.service';
import { AdministrativeLinkingService } from './administrative-linking.service';
import { ApiKeyGuard } from '../common/guards/api-key.guard';
@ApiTags('geocoding')
@@ -10,6 +12,8 @@ export class GeocodingController {
constructor(
private readonly geocodingService: GeocodingService,
private readonly adminBoundariesService: AdminBoundariesService,
private readonly jordanResearchService: JordanResearchService,
private readonly adminLinkingService: AdministrativeLinkingService,
) {}
@Get('search')
@@ -120,4 +124,33 @@ export class GeocodingController {
) {
return this.adminBoundariesService.importFromFile(country, filePath);
}
@Get('research/zarqa')
@ApiOperation({ summary: 'Generate a research report for Zarqa, Jordan (Sample Data)' })
async zarqaResearch() {
return this.jordanResearchService.generateZarqaReport();
}
@Post('admin/sync-neighborhoods')
@UseGuards(ApiKeyGuard)
@ApiOperation({ summary: 'Sync neighborhood points from OSM for a bbox' })
@ApiQuery({ name: 'bbox', required: false })
async syncNeighborhoods(@Query('bbox') bbox?: string) {
return this.adminLinkingService.syncOsmNeighborhoodPoints(bbox);
}
@Post('admin/generate-voronoi')
@UseGuards(ApiKeyGuard)
@ApiOperation({ summary: 'Generate Voronoi polygons for neighborhoods' })
async generateVoronoi() {
return this.adminLinkingService.generateVoronoiNeighborhoods();
}
@Post('admin/link-places')
@UseGuards(ApiKeyGuard)
@ApiOperation({ summary: 'Link places to administrative hierarchy' })
@ApiQuery({ name: 'country', required: true, enum: ['jordan', 'syria'] })
async linkPlaces(@Query('country') country: 'jordan' | 'syria') {
return this.adminLinkingService.linkPlaces(country);
}
}
+15 -2
View File
@@ -10,6 +10,11 @@ import { OsmArea } from './entities/osm-area.entity';
import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
import { AdminBoundary } from './entities/admin-boundary.entity';
import { AdminBoundariesService } from './admin-boundaries.service';
import { JordanResearchService } from './jordan-research.service';
import { AdministrativeLinkingService } from './administrative-linking.service';
import { NeighborhoodPoint } from './entities/neighborhood-point.entity';
import { NeighborhoodPolygon } from './entities/neighborhood-polygon.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
@@ -18,10 +23,18 @@ import { AdminBoundariesService } from './admin-boundaries.service';
PlaceEgypt,
OsmArea,
OsmPointWithArea,
AdminBoundary
AdminBoundary,
NeighborhoodPoint,
NeighborhoodPolygon
]),
],
controllers: [GeocodingController],
providers: [GeocodingService, GeocodingInitService, AdminBoundariesService],
providers: [
GeocodingService,
GeocodingInitService,
AdminBoundariesService,
JordanResearchService,
AdministrativeLinkingService
],
})
export class GeocodingModule {}
+24 -51
View File
@@ -69,6 +69,7 @@ export class GeocodingService {
const userQuery = `
SELECT
p.id, p.name, p.name_ar, p.name_en, p.category,
p.neighborhood_id as db_neighborhood_id,
p.neighbourhood as original_neighbourhood,
n.name_ar as neighbourhood,
d.name_ar as district,
@@ -77,9 +78,9 @@ export class GeocodingService {
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(p.location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
similarity(COALESCE(p.name_ar, ''), $1) + similarity(COALESCE(p.name, ''), $1) + similarity(COALESCE(p.neighbourhood, ''), $1) as relevance
FROM ${tableName} p
LEFT JOIN admin_boundaries n ON p.admin_level10_id = n.id
LEFT JOIN admin_boundaries d ON p.admin_level8_id = d.id
LEFT JOIN admin_boundaries g ON p.admin_level4_id = g.id
LEFT JOIN neighborhood_polygons n ON p.neighborhood_id = n.id
LEFT JOIN admin_boundaries d ON p.sub_district_id = d.id
LEFT JOIN admin_boundaries g ON p.governorate_id = g.id
WHERE (p.name_ar % $1 OR p.name % $1 OR p.neighbourhood % $1 OR p.name_ar ILIKE $4 OR p.name ILIKE $4 OR p.neighbourhood ILIKE $4)
${hasLocation ? `AND (p.location && ST_Expand(ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326), $5::float) OR ST_DistanceSphere(p.location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $6::float)` : ''}
ORDER BY relevance DESC, distance ASC LIMIT 15
@@ -107,55 +108,27 @@ export class GeocodingService {
const osmResults = await this.osmPointsRepository.query(osmQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]);
allResults.push(...osmResults);
// --- Consolidation: Overture Maps Integration (Now in primary DB) ---
const overtureQuery = `
(SELECT id::text,
COALESCE(names->>'primary', names->>'common', 'Building') as name,
COALESCE(names->>'primary', names->>'common', '') as name_ar,
NULL as name_en,
'building' as category,
ST_Y(ST_Centroid(location)) as latitude,
ST_X(ST_Centroid(location)) as longitude,
'' as address,
'overture_buildings' as source,
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
0.6 as relevance
FROM overture_building
WHERE (names->>'primary' ILIKE $4 OR names->>'common' ILIKE $4)
LIMIT 15)
UNION ALL
(SELECT id::text,
COALESCE(names->>'primary', names->>'common', 'Street') as name,
COALESCE(names->>'primary', names->>'common', '') as name_ar,
NULL as name_en,
'transportation' as category,
ST_Y(ST_Centroid(location)) as latitude,
ST_X(ST_Centroid(location)) as longitude,
'' as address,
'overture_streets' as source,
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
0.6 as relevance
FROM overture_segment
WHERE (names->>'primary' ILIKE $4 OR names->>'common' ILIKE $4)
LIMIT 15)
`;
try {
const overtureResults = await this.osmPointsRepository.query(overtureQuery, [cleanQuery, lat || null, lon || null, ILikeQuery]);
allResults.push(...overtureResults);
} catch (err) {
this.logger.warn('Overture tables search failed, logging error.', err);
}
// Note: We removed the raw query to 'overture_building' because raw overture tables
// do not have administrative linking, causing empty full_addresses, and
// scanning them with ILIKE without trigram indices causes a 2-second latency spike.
// Overture data is already properly ingested via the Scraper into places_jordan.
const sortedResults = allResults
.sort((a, b) => hasLocation ? ((a.distance - b.distance) || (b.relevance - a.relevance)) : ((b.relevance - a.relevance) || (a.distance - b.distance)))
.slice(0, 25)
.map(r => ({
...r,
latitude: parseFloat(r.latitude),
longitude: parseFloat(r.longitude),
distance_km: r.distance ? (Number(r.distance) / 1000).toFixed(2) : null,
location: { lat: parseFloat(r.latitude), lng: parseFloat(r.longitude) },
}));
.map(r => {
// Build full administrative address from admin_boundaries
const addressParts = [r.neighbourhood, r.district, r.governorate].filter(Boolean);
const full_address = addressParts.length > 0 ? addressParts.join('، ') : (r.address || '');
return {
...r,
latitude: parseFloat(r.latitude),
longitude: parseFloat(r.longitude),
distance_km: r.distance ? (Number(r.distance) / 1000).toFixed(2) : null,
location: { lat: parseFloat(r.latitude), lng: parseFloat(r.longitude) },
full_address,
};
});
return { results: sortedResults };
} catch (e) {
@@ -177,9 +150,9 @@ export class GeocodingService {
p.latitude, p.longitude, p.address, 'user_place' as source,
ST_DistanceSphere(p.location::geometry, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
FROM ${tableName} p
LEFT JOIN admin_boundaries n ON p.admin_level10_id = n.id
LEFT JOIN admin_boundaries d ON p.admin_level8_id = d.id
LEFT JOIN admin_boundaries g ON p.admin_level4_id = g.id
LEFT JOIN neighborhood_polygons n ON p.neighborhood_id = n.id
LEFT JOIN admin_boundaries d ON p.sub_district_id = d.id
LEFT JOIN admin_boundaries g ON p.governorate_id = g.id
WHERE p.location IS NOT NULL
ORDER BY p.location::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 3
`;
@@ -0,0 +1,139 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PlaceJordan } from './entities/place-jordan.entity';
import axios from 'axios';
import * as fs from 'fs';
import * as path from 'path';
@Injectable()
export class JordanResearchService {
private readonly logger = new Logger(JordanResearchService.name);
constructor(
@InjectRepository(PlaceJordan)
private readonly placeRepo: Repository<PlaceJordan>,
) {}
private async fetchWithRetry(url: string, data?: string, method: 'get' | 'post' = 'get', retries = 2) {
for (let i = 0; i <= retries; i++) {
try {
const config = {
timeout: 20000,
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; IntaleqMapBot/1.0; +https://intaleq.xyz)',
'Accept': 'application/json, application/sparql-results+json'
}
};
const response = method === 'post'
? await axios.post(url, data, config)
: await axios.get(url, { ...config, params: data ? { query: data, format: 'json' } : {} });
return response.data;
} catch (error) {
if (i === retries) {
this.logger.error(`Failed to fetch from ${url} after ${retries} retries: ${error.message}`);
throw error;
}
await new Promise(res => setTimeout(res, 2000));
}
}
}
/**
* Source 1: OSM (Overpass API) with Geometries
*/
async fetchOsmZarqa() {
this.logger.log('Fetching Zarqa Geometries from OSM...');
const query = `[out:json];(relation["boundary"="administrative"]["admin_level"~"6|8|10"](31.86,35.94,32.22,36.25);node["place"~"neighbourhood|suburb|town"](31.86,35.94,32.22,36.25););out geom;`;
try {
const data = await this.fetchWithRetry('https://overpass-api.de/api/interpreter', `data=${encodeURIComponent(query)}`, 'post');
return data.elements.map(e => ({
id: e.id,
name_ar: e.tags['name:ar'] || e.tags['name'],
type: e.tags['admin_level'] ? `admin_level_${e.tags['admin_level']}` : `place_${e.tags['place']}`,
geometry: e.geometry ? e.geometry : (e.lat && e.lon ? { type: 'Point', coordinates: [e.lon, e.lat] } : null)
}));
} catch (e) { return { error: `OSM Fetch failed: ${e.message}` }; }
}
/**
* Source 2: Overture (Local DB) - Corrected columns
*/
async fetchOvertureZarqa() {
try {
const tableCheck = await this.placeRepo.query("SELECT count(*) FROM information_schema.tables WHERE table_name = 'overture_segment'");
if (parseInt(tableCheck[0].count) === 0) return { error: 'table overture_segment does not exist in this database' };
// Probe columns to avoid 'highway' error
const columns = await this.placeRepo.query("SELECT column_name FROM information_schema.columns WHERE table_name = 'overture_segment'");
const colList = columns.map(c => c.column_name);
const roadClassCol = colList.includes('road_class') ? 'road_class' : (colList.includes('class') ? 'class' : 'NULL');
return await this.placeRepo.query(`
SELECT DISTINCT COALESCE(names->>'primary', names->>'common') as name_ar, ${roadClassCol} as road_class,
ST_AsGeoJSON(ST_Centroid(location)) as centroid
FROM overture_segment
WHERE (names->>'primary' IS NOT NULL OR names->>'common' IS NOT NULL)
AND ST_Within(location, ST_MakeEnvelope(35.94, 31.86, 36.25, 32.22, 4326))
LIMIT 10
`);
} catch (e) { return { error: `Overture query failed: ${e.message}` }; }
}
/**
* Source 3: Wikidata (SPARQL) with User-Agent
*/
async fetchWikidataZarqa() {
const sparql = `SELECT ?item ?itemLabel WHERE { ?item wdt:P131 wd:Q231710. SERVICE wikibase:label { bd:serviceParam wikibase:language "ar,en". } } LIMIT 20`;
try {
const data = await this.fetchWithRetry('https://query.wikidata.org/sparql', sparql, 'get');
return data.results.bindings.map(b => ({ id: b.item.value.split('/').pop(), name_ar: b.itemLabel.value }));
} catch (e) { return { error: `Wikidata Fetch failed: ${e.message}` }; }
}
/**
* Source 4 & 5: Static Files (HDX & GADM)
*/
async checkStaticFiles() {
// Check various common paths from overture_ingest script or data directory
const paths = ['/data/infrastructure/osm-data/', './data/', './infrastructure/osm-data/'];
const files = ['jor_adm2_geoboundaries.geojson', 'gadm41_JOR_2.json'];
const results = {};
for (const file of files) {
let found = false;
for (const p of paths) {
if (fs.existsSync(path.join(p, file))) {
results[file] = `Found at ${p}`;
found = true;
break;
}
}
if (!found) results[file] = 'Not Found locally - Check if overture_ingest.sh was run for division_area';
}
return results;
}
async generateZarqaReport() {
const [osm, overture, wikidata, staticFiles] = await Promise.all([
this.fetchOsmZarqa(),
this.fetchOvertureZarqa(),
this.fetchWikidataZarqa(),
this.checkStaticFiles()
]);
return {
area: 'Zarqa Governorate, Jordan',
report_timestamp: new Date().toISOString(),
sources: {
osm,
overture,
wikidata,
static_files: staticFiles,
hdx: "Requires GeoBoundaries GeoJSON for Level 2 (Districts)",
gadm: "Requires GADM v4.1 for Level 1-2"
},
comparison_summary: "Multi-source research enabled with geometries. OSM provides precise neighborhood centroids and boundaries where available. Use the provided centroids to visualize Zarqa subunits."
};
}
}
+25 -2
View File
@@ -294,6 +294,11 @@
<input type="checkbox" id="toggle-pois" checked onchange="updateLayers()" style="accent-color: var(--primary); width: 16px; height: 16px;">
<span>عرض المعالم / Show POIs</span>
</label>
<label style="display: flex; align-items: center; gap: 10px; margin-top: 12px; cursor: pointer; font-size: 13px;">
<input type="checkbox" id="toggle-arrows" checked onchange="updateLayers()" style="accent-color: var(--primary); width: 16px; height: 16px;">
<span>اتجاهات الشوارع / Street Directions</span>
</label>
</div>
<!-- DB NAMES INDICATOR -->
@@ -672,9 +677,15 @@
function updateLayers() {
const show3D = document.getElementById('toggle-3d').checked;
const showPOIs = document.getElementById('toggle-pois').checked;
const showArrows = document.getElementById('toggle-arrows').checked;
if (map.getLayer('3d-buildings')) {
map.setLayoutProperty('3d-buildings', 'visibility', show3D ? 'visible' : 'none');
// Toggle 3D Overture buildings
if (map.getLayer('building-3d')) {
map.setLayoutProperty('building-3d', 'visibility', show3D ? 'visible' : 'none');
}
// Show flat footprints when 3D is off
if (map.getLayer('overture-building-footprint')) {
map.setLayoutProperty('overture-building-footprint', 'visibility', show3D ? 'none' : 'visible');
}
if (map.getLayer('intaleq-db-label')) {
@@ -685,6 +696,18 @@
if (map.getLayer('intaleq_pois')) {
map.setLayoutProperty('intaleq_pois', 'visibility', showPOIs ? 'visible' : 'none');
}
// Toggle places label layers + Overture name layers
['places-syria-labels', 'places-jordan-labels', 'places-egypt-labels', 'overture-building-names', 'overture-street-names'].forEach(id => {
if (map.getLayer(id)) {
map.setLayoutProperty(id, 'visibility', showPOIs ? 'visible' : 'none');
}
});
// Toggle street direction arrows
if (map.getLayer('road-direction-arrows')) {
map.setLayoutProperty('road-direction-arrows', 'visibility', showArrows ? 'visible' : 'none');
}
}
</script>
</body>
+16 -66
View File
@@ -42,6 +42,16 @@
"https://tiles.intaleqapp.com/places_egypt/{z}/{x}/{y}"
],
"maxzoom": 14
},
"overture_buildings": {
"type": "vector",
"tiles": ["https://tiles.intaleqapp.com/overture_building/{z}/{x}/{y}"],
"maxzoom": 14
},
"overture_segments": {
"type": "vector",
"tiles": ["https://tiles.intaleqapp.com/overture_segment/{z}/{x}/{y}"],
"maxzoom": 14
}
},
"layers": [
@@ -909,78 +919,18 @@
{
"id": "building-3d",
"type": "fill-extrusion",
"source": "local-osm-polygons",
"source-layer": "planet_osm_polygon",
"source": "overture_buildings",
"source-layer": "overture_building",
"minzoom": 15,
"filter": [
"has",
"building"
],
"paint": {
"fill-extrusion-color": [
"match",
[
"get",
"building"
],
"commercial",
"#E0D8CC",
"retail",
"#E8DDD0",
"industrial",
"#D8DDE4",
"church",
"#E4DCF0",
"mosque",
"#D4EAD8",
"hospital",
"#F0E0E0",
"school",
"#E8ECD4",
"university",
"#E4E8D4",
"hotel",
"#E0E4EE",
"apartments",
"#E8E4DC",
"#E4DFDA"
],
"fill-extrusion-color": "#DDD8D0",
"fill-extrusion-height": [
"coalesce",
[
"to-number",
[
"get",
"height"
],
0
],
[
"*",
[
"to-number",
[
"get",
"building:levels"
],
3
],
3.5
],
["to-number", ["get", "height"], 0],
["*", ["coalesce", ["to-number", ["get", "num_floors"], null], 3], 3.5],
12
],
"fill-extrusion-base": [
"coalesce",
[
"to-number",
[
"get",
"min_height"
],
0
],
0
],
"fill-extrusion-base": 0,
"fill-extrusion-opacity": 0.85,
"fill-extrusion-vertical-gradient": true
}
+86 -164
View File
@@ -919,6 +919,35 @@
]
}
},
{
"id": "road-direction-arrows",
"type": "symbol",
"source": "local-osm-lines",
"source-layer": "planet_osm_line",
"minzoom": 15,
"filter": [
"all",
["in", "highway", "motorway", "trunk", "primary", "secondary", "tertiary", "residential", "unclassified", "living_street"],
["==", "oneway", "yes"]
],
"layout": {
"symbol-placement": "line",
"symbol-spacing": 80,
"text-field": "▸",
"text-font": ["Noto Sans Regular"],
"text-size": ["interpolate", ["linear"], ["zoom"], 15, 12, 18, 18],
"text-keep-upright": false,
"text-rotation-alignment": "map",
"text-allow-overlap": true,
"text-ignore-placement": true,
"text-padding": 0
},
"paint": {
"text-color": ["interpolate", ["linear"], ["zoom"], 15, "rgba(100,116,139,0.4)", 18, "rgba(100,116,139,0.7)"],
"text-halo-color": "rgba(255,255,255,0.3)",
"text-halo-width": 0.5
}
},
{
"id": "building-fill-flat",
"type": "fill",
@@ -938,120 +967,43 @@
{
"id": "building-3d",
"type": "fill-extrusion",
"source": "local-osm-polygons",
"source-layer": "planet_osm_polygon",
"source": "overture_buildings",
"source-layer": "overture_building",
"minzoom": 14,
"layout": {
"visibility": "none"
"visibility": "visible"
},
"filter": [
"has",
"building"
],
"paint": {
"fill-extrusion-color": [
"match",
[
"get",
"building"
],
"commercial",
"#DDD5C5",
"retail",
"#E5D8C8",
"industrial",
"#D2D8E0",
"church",
"#DDD4EE",
"mosque",
"#CCE4D0",
"hospital",
"#EDD8D8",
"school",
"#E0E6CC",
"university",
"#D8E0C8",
"hotel",
"#D8DCF0",
"apartments",
"#E0DCD4",
"#DDD8D2"
],
"fill-extrusion-color": "#DDD8D0",
"fill-extrusion-height": [
"interpolate",
[
"linear"
],
[
"zoom"
],
["linear"],
["zoom"],
14,
[
"*",
[
"coalesce",
[
"to-number",
[
"get",
"building:levels"
],
null
],
3
],
["coalesce", ["to-number", ["get", "num_floors"], null], 3],
2.5
],
17,
[
"coalesce",
[
"to-number",
[
"get",
"height"
],
null
],
["to-number", ["get", "height"], null],
[
"*",
[
"to-number",
[
"get",
"building:levels"
],
3
],
["coalesce", ["to-number", ["get", "num_floors"], null], 3],
3.5
],
12
]
],
"fill-extrusion-base": [
"coalesce",
[
"to-number",
[
"get",
"min_height"
],
null
],
0
],
"fill-extrusion-base": 0,
"fill-extrusion-opacity": [
"interpolate",
[
"linear"
],
[
"zoom"
],
14,
0.6,
16,
0.88
["linear"],
["zoom"],
14, 0.55,
16, 0.85
],
"fill-extrusion-vertical-gradient": true
}
@@ -1222,33 +1174,28 @@
"unclassified",
"living_street"
],
"minzoom": 16,
"minzoom": 15,
"layout": {
"text-field": [
"coalesce",
[
"get",
"name:ar"
],
[
"get",
"name"
],
["get", "name:ar"],
["get", "name"],
""
],
"text-font": [
"Noto Sans Regular"
],
"text-size": 11,
"text-font": ["Noto Sans Regular"],
"text-size": ["interpolate", ["linear"], ["zoom"], 15, 10, 18, 13],
"symbol-placement": "line",
"text-letter-spacing": 0.04,
"text-padding": 4,
"text-allow-overlap": false
"text-letter-spacing": 0.05,
"text-padding": 15,
"symbol-spacing": 300,
"text-max-angle": 30,
"text-allow-overlap": false,
"text-ignore-placement": false
},
"paint": {
"text-color": "#4A5568",
"text-halo-color": "rgba(255,255,255,0.85)",
"text-halo-width": 1.5
"text-halo-color": "rgba(255,255,255,0.92)",
"text-halo-width": 1.8
}
},
{
@@ -1265,45 +1212,28 @@
"motorway",
"trunk"
],
"minzoom": 13,
"minzoom": 12,
"layout": {
"text-field": [
"coalesce",
[
"get",
"name:ar"
],
[
"get",
"name"
],
["get", "name:ar"],
["get", "name"],
""
],
"text-font": [
"Noto Sans Regular"
],
"text-size": [
"interpolate",
[
"linear"
],
[
"zoom"
],
13,
11,
16,
14
],
"text-font": ["Noto Sans Bold"],
"text-size": ["interpolate", ["linear"], ["zoom"], 12, 10, 14, 13, 18, 16],
"symbol-placement": "line",
"text-letter-spacing": 0.05,
"text-padding": 5,
"text-allow-overlap": false
"text-letter-spacing": 0.06,
"text-padding": 20,
"symbol-spacing": 350,
"text-max-angle": 25,
"text-allow-overlap": false,
"text-ignore-placement": false
},
"paint": {
"text-color": "#2D3748",
"text-halo-color": "rgba(255,255,255,0.9)",
"text-halo-width": 2
"text-color": "#1A2332",
"text-halo-color": "rgba(255,255,255,0.95)",
"text-halo-width": 2.5
}
},
{
@@ -1929,14 +1859,11 @@
"type": "fill",
"source": "overture_buildings",
"source-layer": "overture_building",
"minzoom": 14,
"layout": {
"visibility": "visible"
},
"maxzoom": 14,
"paint": {
"fill-color": "#dcd8d0",
"fill-opacity": 0.8,
"fill-outline-color": "#c4beb4"
"fill-color": "#DDD8D0",
"fill-opacity": 0.85,
"fill-outline-color": "#C4BEB4"
}
},
{
@@ -1978,31 +1905,26 @@
"source": "overture_segments",
"source-layer": "overture_segment",
"minzoom": 14,
"filter": ["has", "names"],
"layout": {
"text-field": [
"coalesce",
[
"get",
"names"
],
""
],
"text-font": [
"Noto Sans Regular"
],
"text-size": 11,
"text-field": ["coalesce", ["get", "names"], ""],
"text-font": ["Noto Sans Regular"],
"text-size": ["interpolate", ["linear"], ["zoom"], 14, 9, 16, 12, 18, 14],
"symbol-placement": "line",
"text-padding": 10,
"text-rotation-alignment": "map",
"text-pitch-alignment": "viewport",
"text-keep-upright": true,
"text-padding": 20,
"text-letter-spacing": 0.04,
"text-max-angle": 30,
"symbol-spacing": 250,
"symbol-spacing": 300,
"text-allow-overlap": false,
"text-ignore-placement": false
},
"paint": {
"text-color": "#666",
"text-halo-color": "rgba(255, 255, 255, 0.9)",
"text-halo-width": 2
"text-color": "#4A5568",
"text-halo-color": "rgba(255, 255, 255, 0.95)",
"text-halo-width": 2.2
}
}
]
+19 -11
View File
@@ -21,13 +21,17 @@ const MapComponent: React.FC<MapComponentProps> = ({
useEffect(() => {
if (!map.current) return;
// Toggle 3D Buildings
// Toggle 3D Buildings (now on Overture source)
if (map.current.getLayer('building-3d')) {
map.current.setLayoutProperty('building-3d', 'visibility', show3D ? 'visible' : 'none');
}
// Toggle flat Overture footprint (show when 3D is off)
if (map.current.getLayer('overture-building-footprint')) {
map.current.setLayoutProperty('overture-building-footprint', 'visibility', show3D ? 'none' : 'visible');
}
// Toggle POI Layers
const poiLayers = ['poi-icons', 'place-labels'];
const poiLayers = ['poi-icons', 'place-labels', 'overture-building-names'];
poiLayers.forEach(layerId => {
if (map.current!.getLayer(layerId)) {
map.current!.setLayoutProperty(layerId, 'visibility', showPOIs ? 'visible' : 'none');
@@ -389,7 +393,7 @@ const MapComponent: React.FC<MapComponentProps> = ({
return (
<div ref={mapContainer} style={{ width: '100%', height: '100%', position: 'relative' }}>
{/* Intaleq Branding Watermark */}
{/* Intaleq Premium Branding Watermark */}
<div
style={{
position: 'absolute',
@@ -398,25 +402,29 @@ const MapComponent: React.FC<MapComponentProps> = ({
zIndex: 10,
display: 'flex',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.7)',
padding: '4px 8px',
borderRadius: '4px',
background: 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'blur(12px) saturate(180%)',
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
padding: '6px 12px',
borderRadius: '10px',
pointerEvents: 'none',
userSelect: 'none',
gap: '6px'
gap: '8px',
boxShadow: '0 4px 16px rgba(0,0,0,0.08)',
border: '1px solid rgba(255,255,255,0.35)'
}}
>
<img
src="/intaleq-logo.png"
alt="Intaleq"
style={{ height: '16px', filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.1))' }}
style={{ height: '18px', filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.1))' }}
/>
<span
style={{
fontSize: '10px',
fontWeight: 600,
color: '#3c4043',
letterSpacing: '0.02em',
fontWeight: 800,
color: '#c0a048',
letterSpacing: '0.8px',
textTransform: 'uppercase'
}}
>
+69
View File
@@ -0,0 +1,69 @@
import os
def export_minimalist_project():
output_filename = "project_source_code_light.txt"
# 1. الملفات المسموح بها بالاسم الكامل (حساسة لحالة الأحرف)
strictly_allowed_names = {'style.json', 'style_mobile.json', '.env', 'env'}
# 2. الامتدادات البرمجية المسموح بها (الكود المصدري فقط)
allowed_logic_extensions = {'.ts', '.sh'}
# 3. مجلدات يتم تجاوزها فوراً لتسريع العملية
ignore_dirs = {'node_modules', 'dist', '.git', 'coverage', 'build', '.idea', '.vscode'}
file_counter = 0
print("جاري استخراج الملفات المطلوبة فقط (Light Mode)...")
with open(output_filename, 'w', encoding='utf-8') as outfile:
outfile.write("=" * 60 + "\n")
outfile.write("ملفات المشروع المختارة (Style JSON + Source Code)\n")
outfile.write("=" * 60 + "\n\n")
for root, dirs, files in os.walk('.'):
# استثناء المجلدات الثقيلة
dirs[:] = [d for d in dirs if d not in ignore_dirs]
for file in files:
name_lower = file.lower()
ext = os.path.splitext(name_lower)[1]
# شرط القبول المنطقي:
# - إما أن يكون الاسم مطابقاً تماماً لملفات الستايل أو env
# - أو أن يكون امتداد ملف كود (ts, sh)
if file in strictly_allowed_names or ext in allowed_logic_extensions:
# استثناء إضافي لملفات الاختبار أو التكوين الفرعية لتقليل الحجم
if '.spec.ts' in name_lower or 'test.ts' in name_lower:
continue
filepath = os.path.join(root, file)
try:
with open(filepath, 'r', encoding='utf-8') as f:
lines = f.readlines()
file_counter += 1
outfile.write("-" * 60 + "\n")
outfile.write(f"[{file_counter}] اسم الملف: {file}\n")
outfile.write(f"المسار: {filepath}\n")
outfile.write("-" * 60 + "\n")
for line_number, line in enumerate(lines, 1):
# كتابة رقم السطر مع الكود
outfile.write(f"{line_number:04d} | {line.rstrip()}\n")
outfile.write("\n\n")
print(f"تمت الإضافة: {filepath}")
except Exception:
# تجاوز أي ملف يسبب خطأ في القراءة
continue
print(f"\n✅ اكتملت العملية بنجاح.")
print(f"إجمالي الملفات المضافة: {file_counter}")
print(f"الملف الناتج: {output_filename}")
if __name__ == "__main__":
export_minimalist_project()
File diff suppressed because it is too large Load Diff